diff --git a/package.json b/package.json index 2f4f07a..fa98c33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphiql-code-exporter", - "version": "3.0.2", + "version": "3.0.3", "description": "Export working code snippets from GraphiQL queries", "main": "lib/index.js", "module": "es/index.js", diff --git a/src/CodeExporter.css b/src/CodeExporter.css index 9740fb3..98f1d53 100644 --- a/src/CodeExporter.css +++ b/src/CodeExporter.css @@ -10,3 +10,56 @@ .graphiql-code-exporter .CodeMirror-cursors { display: none; } + +.flex-wrapper { + max-height: 60vh; + background: #ccc; + display: flex; + flex-direction: column; +} +.flex-wrapper .header, +.footer { + height: unset; + background: #666; + color: #fff; + padding: 4px; +} +.flex-wrapper .content { + display: flex; + flex: 1; + background: #999; + color: #000; +} +.flex-wrapper .columns { + display: flex; + flex: 1; + flex-direction: column; +} +.flex-wrapper .changed { + flex: 1; + order: 1; + background: #eee; + padding: 6px; + overflow-y: scroll; +} +.flex-wrapper .sidebar-new { + background: #ccc; + flex: 1 1; + order: 2; + padding: 6px; + max-height: 33%; + overflow-y: scroll; +} +.flex-wrapper .sidebar-unchanged { + order: 3; + flex: 1 1; + max-height: 33%; + overflow-y: scroll; + background: #ddd; + padding: 6px; +} + +.flex-wrapper h1 { + color: white; + padding: 6px; +} diff --git a/src/CodeExporter.js b/src/CodeExporter.js index 32bdf77..8e50cb9 100644 --- a/src/CodeExporter.js +++ b/src/CodeExporter.js @@ -1,29 +1,45 @@ // @flow import React, {Component} from 'react'; import copy from 'copy-to-clipboard'; -import {parse, print} from 'graphql'; +import { + BREAK, + getNamedType, + GraphQLInputObjectType, + GraphQLObjectType, + parse, + print, + visit, + visitWithTypeInfo, + TypeInfo, + printSchema, +} from 'graphql'; // $FlowFixMe: can't find module import CodeMirror from 'codemirror'; import toposort from './toposort.js'; +import Modal from './Modal'; + +import * as OG from './OneGraphOperations'; import type { GraphQLSchema, + FieldNode, FragmentDefinitionNode, OperationDefinitionNode, VariableDefinitionNode, + VariableNode, + NameNode, OperationTypeNode, SelectionSetNode, } from 'graphql'; +import './CodeExporter.css'; + function formatVariableName(name: string) { var uppercasePattern = /[A-Z]/g; return ( name.charAt(0).toUpperCase() + - name - .slice(1) - .replace(uppercasePattern, '_$&') - .toUpperCase() + name.slice(1).replace(uppercasePattern, '_$&').toUpperCase() ); } @@ -38,6 +54,7 @@ const copyIcon = ( ); +// eslint-disable-next-line const codesandboxIcon = ( ); +// eslint-disable-next-line +const gitHubIcon = ( + + {'GitHub icon'} + + +); + +const downArrow = (props) => ( + + + +); + +const gitPushIcon = ( + + + +); + +const gitNewRepoIcon = ( + + + +); + +type ShallowFragmentVariables = { + [string]: { + variables: ?Array<{name: string, type: string}>, + }, +}; export type Variables = {[key: string]: ?mixed}; // TODO: Need clearer separation between option defs and option values @@ -79,6 +139,7 @@ export type GenerateOptions = { context: Object, operationDataList: Array, options: OptionValues, + schema: ?GraphQLSchema, }; export type CodesandboxFile = { @@ -98,12 +159,161 @@ export type Snippet = { generateCodesandboxFiles?: ?(options: GenerateOptions) => CodesandboxFiles, }; +type NamedPath = Array; + +const snippetStorageName = (snippet) => { + return `${snippet.language}:${snippet.name}`; +}; + +export const namedPathOfAncestors = ( + ancestors: ?$ReadOnlyArray>, +): namedPath => + (ancestors || []).reduce((acc, next) => { + if (Array.isArray(next)) { + return acc; + } + switch (next.kind) { + case 'Field': + return [...acc, next.name.value]; + case 'InlineFragment': + return [...acc, `$inlineFragment.${next.typeCondition.name.value}`]; + case 'Argument': + return [...acc, `$arg.${next.name.value}`]; + default: + return acc; + } + }, []); + +const findVariableTypeFromAncestorPath = ( + schema: GraphQLSchema, + definitionNode: FragmentDefinitionNode, + variable: VariableNode, + ancestors: ?$ReadOnlyArray>, +): ?{name: string, type: any} => { + const namePath = namedPathOfAncestors(ancestors); + + // $FlowFixMe: Optional chaining + const usageAst = ancestors.slice(-1)?.[0]?.find((argAst) => { + return argAst.value?.name?.value === variable.name.value; + }); + + if (!usageAst) { + return; + } + + const argObjectValueHelper = ( + inputObj: GraphQLInputObjectType, + path: Array, + parentField: ?any, + ): ?{name: string, type: any} => { + if (path.length === 0) { + const finalInputField = inputObj.getFields()[usageAst.name.value]; + return { + name: variable.name.value, + type: finalInputField.type, + }; + } + + const [next, ...rest] = path; + const field = inputObj.getFields()[next]; + const namedType = getNamedType(field.type); + if (!!namedType && namedType instanceof GraphQLInputObjectType) { + return argObjectValueHelper(namedType, rest, field); + } + }; + + const argByName = (field, name) => + field && field.args.find((arg) => arg.name === name); + + const helper = ( + obj: GraphQLObjectType, + path: Array, + parentField: ?any, + ): ?{name: string, type: any} => { + if ((path || []).length === 0) { + const arg = argByName(parentField, usageAst.name.value); + if (!!arg) { + return {name: variable.name.value, type: arg.type}; + } + } + + const [next, ...rest] = path; + if (!next) { + console.warn( + 'Next is null before finding target (this may be normal) ', + variable, + namePath, + definitionNode, + ); + return; + } + const nextIsArg = next.startsWith('$arg.'); + const nextIsInlineFragment = next.startsWith('$inlineFragment.'); + + if (nextIsArg) { + const argName = next.replace('$arg.', ''); + const arg = argByName(parentField, argName); + if (!arg) { + console.warn('Failed to find arg: ', argName); + return; + } + const inputObj = getNamedType(arg.type); + + if (!!inputObj) { + return argObjectValueHelper(inputObj, rest); + } + } else if (nextIsInlineFragment) { + const typeName = next.replace('$inlineFragment.', ''); + const type = schema.getType(typeName); + const namedType = type && getNamedType(type); + const field = namedType.getFields()[next]; + + return helper(namedType, rest, field); + } else { + const field = obj.getFields()[next]; + const namedType = getNamedType(field.type); + + // TODO: Clean up this mess + if ((rest || []).length === 0) { + // Dummy use of `obj` since I botched the recursion base case + return helper(obj, rest, field); + } else { + if (!!namedType && !!namedType.getFields) { + // $FlowFixMe: Not sure how to type a "GraphQL object that has getFields" + return helper(namedType, rest, field); + } + } + } + }; + + const isDefinitionNode = [ + 'OperationDefinition', + 'FragmentDefinition', + ].includes(definitionNode.kind); + + if (!isDefinitionNode) { + return; + } + + const rootType = + definitionNode.kind === 'FragmentDefinition' + ? schema.getType(definitionNode.typeCondition.name.value) + : null; + + if (!!rootType && !!rootType.getFields) { + // $FlowFixMe: Not sure how to type a "GraphQL object that has getFields" + return helper(rootType, namePath); + } +}; + export const computeOperationDataList = ({ query, variables, + schema, }: { query: string, variables: Variables, + schema: ?GraphQLSchema, }) => { const operationDefinitions = getOperationNodes(query); @@ -128,20 +338,40 @@ export const computeOperationDataList = ({ variables: getUsedVariables(variables, operationDefinition), operationDefinition, fragmentDependencies: findFragmentDependencies( + schema, fragmentDefinitions, operationDefinition, ), + paginationSites: + schema && findPaginationSites(schema, operationDefinition), }), ); const operationDataList = toposort(rawOperationDataList); - return { + let fragmentVariables; + if (!!schema) { + const shallowFragmentVariables = collectFragmentVariables( + schema, + fragmentDefinitions, + ); + + fragmentVariables = computeDeepFragmentVariables( + schema, + operationDataList, + shallowFragmentVariables, + ); + } + + const result = { operationDefinitions: operationDefinitions, fragmentDefinitions: fragmentDefinitions, rawOperationDataList: rawOperationDataList, operationDataList: operationDataList, + fragmentVariables: fragmentVariables, }; + + return result; }; async function createCodesandbox( @@ -166,12 +396,564 @@ async function createCodesandbox( } } +const findPaginationSites = ( + schema: GraphQLSchema, + operationDefinition: OperationDefinitionNode | FragmentDefinitionNode, +) => { + var typeInfo = new TypeInfo(schema); + var paginationSites = []; + let previousNode; + + const hasArgByNameAndTypeName = (field, argName, typeName) => { + return field.args.some( + (arg) => arg.name === argName && arg.type.name === typeName, + ); + }; + + visit( + operationDefinition, + visitWithTypeInfo(typeInfo, { + Field: { + enter: (node, key, parent, path, ancestors) => { + const namedType = getNamedType(typeInfo.getType()); + const typeName = namedType?.name; + const parentType = typeInfo.getParentType(); + const parentNamedType = parentType && getNamedType(parentType); + const parentTypeName = parentNamedType?.name; + + const isConnectionCandidate = + !!parentTypeName?.endsWith('Connection') && + !!typeName.endsWith('Edge') && + !!previousNode; + + if (typeName.endsWith('Connection')) { + previousNode = { + node, + namedType: getNamedType(typeInfo.getType()), + parent: parentNamedType, + field: parentNamedType?.getFields()?.[node.name.value], + }; + } else if (isConnectionCandidate) { + const parentField = previousNode?.field; + const parentHasConnectionArgs = + hasArgByNameAndTypeName(parentField, 'first', 'Int') && + hasArgByNameAndTypeName(parentField, 'after', 'String'); + + const hasConnectionSelection = + node.name?.value === 'edges' && + node.selectionSet?.selections?.some( + (sel) => sel.name?.value === 'node', + ); + + const hasPageInfoType = !!getNamedType( + parentNamedType?.getFields()?.['pageInfo']?.type, + )?.name?.endsWith('PageInfo'); + + const conformsToConnectionSpec = + parentHasConnectionArgs && + hasConnectionSelection && + hasPageInfoType; + + if (conformsToConnectionSpec) { + paginationSites.push([node, [...ancestors]]); + } + + previousNode = null; + } else { + previousNode = null; + } + }, + }, + }), + ); + + return paginationSites; +}; + +const baseFragmentDefinition = { + kind: 'FragmentDefinition', + typeCondition: { + kind: 'NamedType', + name: { + kind: 'Name', + value: 'TypeName', + }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [], + }, + name: { + kind: 'Name', + value: 'PlaceholderFragment', + }, + directives: [], +}; + +export const extractNodeToConnectionFragment = ({ + schema, + node, + moduleName, + propName, + typeConditionName, +}: { + schema: GraphQLSchema, + node: FieldNode, + moduleName: string, + propName: string, + typeConditionName: string, +}) => { + const fragmentName = `${moduleName}_${propName}`; + + const canonicalArgumentNameMapping = { + first: 'count', + after: 'cursor', + last: 'last', + before: 'cursor', + }; + const connectionFirstArgument = { + kind: 'Argument', + name: { + kind: 'Name', + value: 'first', + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: 'count', + }, + }, + }; + + const connectionAfterArgument = { + kind: 'Argument', + name: { + kind: 'Name', + value: 'after', + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: 'cursor', + }, + }, + }; + + const hasFirstArgument = (node.arguments || []).some( + (arg) => arg.name.value === 'first', + ); + const hasAfterArgument = (node.arguments || []).some( + (arg) => arg.name.value === 'after', + ); + + const namedType = schema.getType(typeConditionName); + + const namedTypeHasId = !!(namedType && namedType.getFields().id); + + const args = node?.arguments?.map((arg) => { + const variableName = canonicalArgumentNameMapping[arg.name.value]; + + return !!variableName + ? { + ...arg, + value: { + ...arg.value, + kind: 'Variable', + name: {kind: 'Name', value: variableName}, + }, + } + : arg; + }); + + if (!hasFirstArgument) { + args.push(connectionFirstArgument); + } + + if (!hasAfterArgument) { + args.push(connectionAfterArgument); + } + + const connectionKeyName = `${fragmentName}_${ + (node.alias && node.alias.value) || node.name.value + }`; + + const tempFragmentDefinition = { + ...baseFragmentDefinition, + name: {...baseFragmentDefinition.name, value: fragmentName}, + typeCondition: { + ...baseFragmentDefinition.typeCondition, + name: { + ...baseFragmentDefinition.typeCondition.name, + value: typeConditionName, + }, + }, + directives: [], + selectionSet: { + ...baseFragmentDefinition.selectionSet, + selections: [ + // Add id field automatically for store-based connections like Relay + ...(namedTypeHasId + ? [ + { + kind: 'Field', + name: { + kind: 'Name', + value: 'id', + }, + directives: [], + }, + ] + : []), + { + ...node, + directives: [ + { + kind: 'Directive', + name: { + kind: 'Name', + value: 'connection', + }, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'key'}, + value: { + kind: 'StringValue', + value: connectionKeyName, + block: false, + }, + }, + ], + }, + ], + arguments: [...(args || [])], + }, + ], + }, + }; + + const allFragmentVariables = findFragmentVariables( + schema, + tempFragmentDefinition, + ); + + const fragmentVariables = + allFragmentVariables[tempFragmentDefinition.name.value] || []; + + const usedArgumentDefinition = fragmentVariables + .filter(Boolean) + .map(({name, type}) => { + if (!['count', 'first', 'after', 'cursor'].includes(name)) { + return {name: name, type: type.toString()}; + } + + return null; + }) + .filter(Boolean); + + const hasCountArgumentDefinition = usedArgumentDefinition.some( + (argDef) => argDef.name === 'count', + ); + const hasCursorArgumentDefinition = usedArgumentDefinition.some( + (argDef) => argDef.name === 'cursor', + ); + + const baseArgumentDefinitions = [ + hasCountArgumentDefinition + ? null + : { + name: 'count', + type: 'Int', + defaultValue: {kind: 'IntValue', value: '10'}, + }, + hasCursorArgumentDefinition ? null : {name: 'cursor', type: 'String'}, + ].filter(Boolean); + + const argumentDefinitions = makeArgumentsDefinitionsDirective([ + ...baseArgumentDefinitions, + ...usedArgumentDefinition, + ]); + + return {...tempFragmentDefinition, directives: [argumentDefinitions]}; +}; + +export const astByNamedPath = (ast, namedPath, customVisitor) => { + let remaining = [...namedPath]; + let nextName = remaining[0]; + let target; + let baseVisitor = { + InlineFragment: (node, key, parent, path, ancestors) => { + const nextIsInlineFragment = nextName.startsWith('$inlineFragment.'); + if (nextIsInlineFragment) { + const typeName = nextName.replace('$inlineFragment.', ''); + const isNextTargetNode = node.typeCondition.name.value === typeName; + + if (isNextTargetNode) { + if (remaining?.length === 1 && isNextTargetNode) { + target = {node, key, parent, path, ancestors: [...ancestors]}; + return BREAK; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + } + } + } + }, + Field: (node, key, parent, path, ancestors) => { + const isNextTargetNode = node.name.value === nextName; + if (remaining?.length === 1 && isNextTargetNode) { + target = {node, key, parent, path, ancestors: [...ancestors]}; + return BREAK; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + } + }, + }; + + let visitor = customVisitor ? customVisitor(baseVisitor) : baseVisitor; + + visit(ast, visitor); + return target; +}; + +export const findUnusedOperationVariables = ( + operationDefinition: OperationDefinitionNode, +) => { + const variableNames = (operationDefinition.variableDefinitions || []).map( + (def) => { + return def.variable.name.value; + }, + ); + + let unusedVariables = new Set(variableNames); + + let baseVisitor = { + Variable: (node, key, parent, path, ancestors) => { + if (variableNames.includes(node.name.value)) { + unusedVariables.delete(node.name.value); + } + }, + }; + + let visitor = baseVisitor; + + visit(operationDefinition.selectionSet, visitor); + return unusedVariables; +}; + +export const pruneOperationToNamedPath = ( + operationDefinition: OperationDefinitionNode, + namedPath: NamedPath, +): OperationDefinitionNode => { + let remaining = [...namedPath]; + let nextName = remaining[0]; + + const processNode = (node, key, parent, path, ancestors) => { + const isNextTargetNode = node.name.value === nextName; + if (remaining?.length === 1 && isNextTargetNode) { + return false; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + return; + } else { + return null; + } + }; + + const result = visit(operationDefinition, { + Field: processNode, + FragmentSpread: processNode, + }); + + return result; +}; + +export const updateAstAtPath = (ast, namedPath, updater, customVisitor) => { + let remaining = [...namedPath]; + let nextName = remaining[0]; + + let baseVisitor = { + Field: (node, key, parent, path, ancestors) => { + const isNextTargetNode = node.name.value === nextName; + if ((remaining || []).length === 1 && isNextTargetNode) { + return updater(node, key, parent, path, ancestors); + } else if ((remaining || []).length === 0) { + return false; + } else if (isNextTargetNode) { + remaining = remaining.slice(1); + nextName = remaining[0]; + } + }, + }; + + let visitor = customVisitor ? customVisitor(baseVisitor) : baseVisitor; + + return visit(ast, visitor); +}; + +export const renameOperation = ( + operationDefinition: OperationDefinitionNode, + name: string, +): OperationDefinitionNode => { + const nameNode: NameNode = !!operationDefinition.name?.value + ? {...operationDefinition.name, value: name} + : {kind: 'Name', value: name}; + return {...operationDefinition, name: nameNode}; +}; + +export const makeAstDirective = ({ + name, + args, +}: { + name: string, + args: Array, +}) => { + return { + kind: 'Directive', + name: { + kind: 'Name', + value: name, + }, + arguments: args, + }; +}; + +export const makeArgumentsDefinitionsDirective = ( + defs: Array<{ + name: string, + type: string, + defaultValue?: { + kind: string, + value: any, + }, + }>, +) => { + const astDirective = makeAstDirective({ + name: 'argumentDefinitions', + args: defs.map((def) => { + const defaultValueField = !!def.defaultValue + ? [ + { + kind: 'ObjectField', + name: { + kind: 'Name', + value: 'defaultValue', + }, + value: { + kind: def.defaultValue.kind, + value: def.defaultValue.value, + }, + }, + ] + : []; + + return { + kind: 'Argument', + name: { + kind: 'Name', + value: def.name, + }, + value: { + kind: 'ObjectValue', + fields: [ + { + kind: 'ObjectField', + name: { + kind: 'Name', + value: 'type', + }, + value: { + kind: 'StringValue', + value: def.type, + block: false, + }, + }, + ...defaultValueField, + ], + }, + }; + }), + }); + + return astDirective; +}; + +export const makeArgumentsDirective = ( + defs: Array<{ + argName: string, + variableName: string, + }>, +) => { + return makeAstDirective({ + name: 'arguments', + args: defs.map((def) => { + return { + kind: 'Argument', + name: { + kind: 'Name', + value: def.name, + }, + value: { + kind: 'Variable', + name: { + kind: 'Name', + value: def.variableName, + }, + }, + }; + }), + }); +}; + +export const findFragmentVariables = ( + schema: GraphQLSchema, + def: FragmentDefinitionNode, +) => { + if (!schema) { + return {}; + } + + const typeInfo = new TypeInfo(schema); + + let fragmentVariables = {}; + + visit( + def, + visitWithTypeInfo(typeInfo, { + Variable: function (node, key, parent, path, ancestors) { + const usedVariables = findVariableTypeFromAncestorPath( + schema, + def, + node, + ancestors, + ); + const existingVariables = fragmentVariables[def.name.value] || []; + const alreadyHasVariable = + // TODO: Don't filter boolean, fix findVariableTypeFromAncestorPath + existingVariables + .filter(Boolean) + .some((existingDef) => existingDef.name === def.name.value); + fragmentVariables[def.name.value] = alreadyHasVariable + ? existingVariables + : [...existingVariables, usedVariables]; + }, + }), + ); + + return fragmentVariables; +}; + let findFragmentDependencies = ( + schema: ?GraphQLSchema, operationDefinitions: Array, def: OperationDefinitionNode | FragmentDefinitionNode, ): Array => { const fragmentByName = (name: string) => { - return operationDefinitions.find(def => def.name.value === name); + return operationDefinitions.find((def) => def.name.value === name); }; const findReferencedFragments = ( @@ -180,9 +962,11 @@ let findFragmentDependencies = ( const selections = selectionSet.selections; const namedFragments = selections - .map(selection => { + .map((selection) => { if (selection.kind === 'FragmentSpread') { - return fragmentByName(selection.name.value); + const fragmentDef = fragmentByName(selection.name.value); + + return fragmentDef; } else { return null; } @@ -213,6 +997,84 @@ let findFragmentDependencies = ( return findReferencedFragments(selectionSet); }; +let collectFragmentVariables = ( + schema: ?GraphQLSchema, + operationDefinitions: Array, +): ShallowFragmentVariables => { + const entries = operationDefinitions + .map((fragmentDefinition) => { + let usedVariables = {}; + + if (!!schema && fragmentDefinition.kind === 'FragmentDefinition') { + usedVariables = findFragmentVariables(schema, fragmentDefinition); + } + + return usedVariables; + }) + .reduce((acc, next) => Object.assign(acc, next), {}); + + return entries; +}; + +const computeDeepFragmentVariables = ( + schema: GraphQLSchema, + operationDataList: Array, + shallowFragmentVariables: ShallowFragmentVariables, +) => { + const fragmentByName = (name: string) => { + return operationDataList.find( + (operationData) => operationData.operationDefinition.name?.value === name, + ); + }; + + const entries = operationDataList + .map((operationData) => { + const operation = operationData.operationDefinition; + if (operation.kind === 'FragmentDefinition' && !!operation.name) { + const localVariables = + shallowFragmentVariables[operation.name.value] || []; + const visitedFragments = new Set(); + + const helper = (deps) => { + return deps.reduce((acc, dep) => { + const depName = dep.name.value; + if (visitedFragments.has(depName)) { + return acc; + } else { + visitedFragments.add(depName); + const depLocalVariables = shallowFragmentVariables[depName] || []; + const subDep = fragmentByName(depName); + if (subDep) { + const subDeps = helper(subDep.fragmentDependencies); + return {...acc, [depName]: depLocalVariables, ...subDeps}; + } else { + return {...acc, [depName]: depLocalVariables}; + } + } + }, []); + }; + + let deepFragmentVariables = helper(operationData.fragmentDependencies); + + return [ + operation.name.value, + { + shallow: localVariables, + deep: { + [operation.name.value]: localVariables, + ...deepFragmentVariables, + }, + }, + ]; + } else { + return null; + } + }) + .filter(Boolean); + + return Object.fromEntries(entries); +}; + let operationNodesMemo: [ ?string, ?Array, @@ -292,8 +1154,8 @@ export class ToolbarMenu extends Component< e.preventDefault()} - ref={node => { + onMouseDown={(e) => e.preventDefault()} + ref={(node) => { this._node = node; }} title={this.props.title}> @@ -365,7 +1227,7 @@ class CodeDisplay extends React.PureComponent { } render() { - return
(this._node = ref)} />; + return
(this._node = ref)} />; } } @@ -385,6 +1247,26 @@ type Props = {| onGenerateCodesandbox?: ?({sandboxId: string}) => void, schema: ?GraphQLSchema, |}; + +type GitHubRepositoryIdentifier = {| + owner: string, + name: string, + branch: string, +|}; + +const descriptionOfGitHubRepositoryIdentifier = ( + repoIdentifier: GitHubRepositoryIdentifier, +): string => { + return `${repoIdentifier.owner}/${repoIdentifier.name}:"${repoIdentifier.branch}"`; +}; +type GitHubInfo = {| + login: ?string, + targetRepo: ?GitHubRepositoryIdentifier, + availableRepositories: ?Array, + repoSearch: ?string, + searchOpen: boolean, +|}; + type State = {| showCopiedTooltip: boolean, optionValuesBySnippet: Map, @@ -394,6 +1276,13 @@ type State = {| | {type: 'loading'} | {type: 'success', sandboxId: string} | {type: 'error', error: string}, + gitHubPushResult: + | null + | {type: 'loading'} + | {type: 'success'} + | {type: 'error', error: string}, + gitHubInfo: ?GitHubInfo, + forceOverwriteFiles: {[string]: boolean}, |}; class CodeExporter extends Component { @@ -403,6 +1292,9 @@ class CodeExporter extends Component { optionValuesBySnippet: new Map(), snippet: null, codesandboxResult: null, + gitHubPushResult: null, + gitHubInfo: {searchOpen: false}, + snippetStorage: null, }; _activeSnippet = (): Snippet => @@ -410,12 +1302,17 @@ class CodeExporter extends Component { setSnippet = (snippet: Snippet) => { this.props.onSelectSnippet && this.props.onSelectSnippet(snippet); - this.setState({snippet, codesandboxResult: null}); + + const snippetStorage = JSON.parse( + localStorage.getItem(snippetStorageName(snippet)) || '{}', + ); + + this.setState({snippet, snippetStorage, codesandboxResult: null}); }; setLanguage = (language: string) => { const snippet = this.props.snippets.find( - snippet => snippet.language === language, + (snippet) => snippet.language === language, ); if (snippet) { @@ -445,8 +1342,10 @@ class CodeExporter extends Component { }; }; - _generateCodesandbox = async (operationDataList: Array) => { - this.setState({codesandboxResult: {type: 'loading'}}); + _generateFiles = async ( + operationDataList: Array, + fragmentVariables, + ) => { const snippet = this._activeSnippet(); if (!snippet) { // Shouldn't be able to get in this state, but just in case... @@ -467,11 +1366,47 @@ class CodeExporter extends Component { return; } try { - const sandboxResult = await createCodesandbox( - generateFiles( - this._collectOptions(snippet, operationDataList, this.props.schema), - ), + const {query, variables = {}} = this.props; + + const {fragmentVariables, operationDataList} = computeOperationDataList({ + query, + variables, + schema: this.props.schema, + }); + + const generateOptions = this._collectOptions( + snippet, + operationDataList, + this.props.schema, + fragmentVariables, ); + + const files = await generateFiles(generateOptions); + + return files; + } catch (e) { + console.error('Error generating files', e); + } + }; + + _generateCodesandbox = async ( + operationDataList: Array, + fragmentVariables, + ) => { + this.setState({codesandboxResult: {type: 'loading'}}); + + try { + const files = await this._generateFiles( + operationDataList, + fragmentVariables, + ); + + if (!files) { + console.warn('No files generated'); + return; + } + + const sandboxResult = await createCodesandbox(files); this.setState({ codesandboxResult: {type: 'success', ...sandboxResult}, }); @@ -488,13 +1423,120 @@ class CodeExporter extends Component { } }; + _pushChangesToGitHub = async ( + operationDataList: Array, + fragmentVariables, + fileOverwriteList: {[string]: boolean}, + ) => { + this.setState({gitHubPushResult: {type: 'loading'}}); + + const targetRepo = this.state.gitHubInfo?.targetRepo; + + if (!targetRepo) { + this.setState({ + gitHubPushResult: { + type: 'error', + error: 'You must select a target GitHub repository to sync with', + }, + }); + + return; + } + + try { + const files = await this._generateFiles( + operationDataList, + fragmentVariables, + ); + + if (!files) { + console.warn('No files generated'); + return; + } + + const transformedFiles = Object.entries(files) + .filter(([path, details]) => { + return typeof fileOverwriteList[path] === 'undefined' + ? true + : fileOverwriteList[path]; + }) + .map(([path, details]) => { + const fileContent = + typeof details.content === 'object' + ? JSON.stringify(details.content, null, 2) + : details.content; + + return { + path: path, + mode: '100644', + content: fileContent, + }; + }); + + const acceptOverrides = Object.entries(fileOverwriteList).length > 0; + + const pushResults = await OG.pushFilesToBranch({ + owner: targetRepo.owner, + name: targetRepo.name, + branch: targetRepo.branch, + message: 'Updates from OneGraph code generator', + treeFiles: transformedFiles, + acceptOverrides, + }); + + if (!!pushResults) { + if (typeof pushResults.error === 'string') { + this.setState({ + gitHubPushResult: { + type: 'error', + error: pushResults.error, + }, + }); + } else if (!!pushResults.confirmationNeeded) { + this.setState((oldState) => { + return { + gitHubPushResult: { + type: 'error', + error: 'Confirm file overwrite', + }, + gitHubConfirmationModal: { + changeset: pushResults.changeset, + }, + }; + }); + } else if (!!pushResults.ok) { + this.setState({ + gitHubPushResult: { + type: 'success', + }, + }); + setTimeout(() => { + this.setState({ + gitHubPushResult: null, + }); + }, 2500); + } + } + } catch (e) { + console.error('Error in GitHub synch', e); + this.setState({ + gitHubPushResult: { + type: 'error', + error: 'Failed to sync with GitHub', + }, + }); + } + }; + _collectOptions = ( snippet: Snippet, operationDataList: Array, schema: ?GraphQLSchema, + fragmentVariables, ): GenerateOptions => { const {serverUrl, context = {}, headers = {}} = this.props; const optionValues = this.getOptionValues(snippet); + return { serverUrl, headers, @@ -502,39 +1544,132 @@ class CodeExporter extends Component { operationDataList, options: optionValues, schema, + fragmentVariables, }; }; + setOverwritePath({path, overwrite}: {path: string, overwrite: boolean}) { + const newSnippetStorage = { + ...this.state.snippetStorage, + overwriteSettings: { + ...(this.state.snippetStorage?.overwriteSettings || {}), + [path]: overwrite, + }, + }; + localStorage.setItem( + snippetStorageName(this._activeSnippet()), + JSON.stringify(newSnippetStorage), + ); + this.setState({snippetStorage: newSnippetStorage}); + } + + setTargetRepo(owner: string, name: string, branch: string) { + const newSnippetStorage = { + ...this.state.snippetStorage, + targetRepo: { + owner: owner, + name: name, + branch: branch, + }, + }; + + localStorage.setItem( + snippetStorageName(this._activeSnippet()), + JSON.stringify(newSnippetStorage), + ); + + this.setState((oldState) => { + return { + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + searchOpen: false, + targetRepo: { + owner: owner, + name: name, + branch: branch, + }, + }, + }; + }); + } + + componentDidMount() { + const snippetStorage = JSON.parse( + localStorage.getItem(snippetStorageName(this._activeSnippet())) || '{}', + ); + + this.setState({snippetStorage: snippetStorage}); + + OG.fetchFindMeOnGitHub().then((result) => { + const rawGitHubInfo = result.data?.me?.github; + if (!!rawGitHubInfo) { + const availableRepositories = rawGitHubInfo.repositories.edges.map( + (edge) => { + const [owner, name] = edge.node.nameWithOwner.split('/'); + return { + owner: owner, + name: name, + branch: 'main', + }; + }, + ); + + const defaultRepo = snippetStorage?.targetRepo; + + const gitHubInfo = { + login: rawGitHubInfo.login, + availableRepositories: availableRepositories, + targetRepo: defaultRepo || availableRepositories[0], + }; + + this.setState((oldState) => ({ + ...oldState, + gitHubInfo: {...oldState.gitHubInfo, ...gitHubInfo}, + })); + } + }); + } + render() { const {query, snippets, variables = {}} = this.props; - const {showCopiedTooltip, codesandboxResult} = this.state; + const {showCopiedTooltip, codesandboxResult, gitHubPushResult} = this.state; const snippet = this._activeSnippet(); const {name, language, generate} = snippet; + const gitHubSearchOpen = this.state.gitHubInfo?.searchOpen; + const { - operationDefinitions: operationDefinitions, - fragmentDefinitions: fragmentDefinitions, - rawOperationDataList: rawOperationDataList, - operationDataList: operationDataList, - } = computeOperationDataList({query, variables}); + fragmentVariables, + operationDefinitions, + operationDataList, + } = computeOperationDataList({ + query, + variables, + schema: this.props.schema, + }); const optionValues: Array = this.getOptionValues(snippet); - const codeSnippet = operationDefinitions.length ? generate( - this._collectOptions(snippet, operationDataList, this.props.schema), + this._collectOptions( + snippet, + operationDataList, + this.props.schema, + fragmentVariables, + ), ) : null; const supportsCodesandbox = snippet.generateCodesandboxFiles; const languages = [ - ...new Set(snippets.map(snippet => snippet.language)), + ...new Set(snippets.map((snippet) => snippet.language)), ].sort((a, b) => a.localeCompare(b)); return ( -
+
{ {snippets - .filter(snippet => snippet.language === language) - .map(snippet => ( + .filter((snippet) => snippet.language === language) + .map((snippet) => (
  • this.setSnippet(snippet)}> @@ -571,7 +1706,7 @@ class CodeExporter extends Component { }}> Options
  • - {snippet.options.map(option => ( + {snippet.options.map((option) => (
    { )} {supportsCodesandbox ? (
    - +
    + {(this.state.gitHubInfo?.availableRepositories || []).length > + 0 ? ( + +
  • { + this._pushChangesToGitHub( + operationDataList, + fragmentVariables, + {}, + ); + + return true; + }}> + {gitPushIcon} Push changes +
  • +
  • { + const repoName = prompt( + 'Name of new GitHub repository', + ); + if (!repoName?.trim()) { + return; + } + + const result = await OG.executeCreateRepo(repoName); + const repo = + result?.data?.gitHub?.makeRestCall?.post?.jsonBody; + + if (!repo) { + this.setState({ + gitHubPushResult: { + type: 'error', + error: 'Failed to create GitHub repository', + }, + }); + return; + } + + this.setTargetRepo( + repo?.owner?.login, + repo?.name, + 'main', + ); + }}> + {gitNewRepoIcon} New repository +
  • +
  • { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + this.setState((oldState) => { + return { + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + searchOpen: !gitHubSearchOpen, + }, + }; + }); + }} + onMouseDown={(event) => { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + return false; + }}> + Change repository{' '} + {downArrow( + gitHubSearchOpen + ? null + : {style: {transform: 'rotate(-90deg)'}}, + )} +
  • + {gitHubSearchOpen ? ( +
  • + { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + }} + onMouseDown={(event) => { + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation(); + return false; + }} + onChange={(event) => { + const value = event.target.value; + let repoSearch = value; + if (value.trim() === '') { + repoSearch = null; + } + + this.setState((oldState) => { + return { + ...oldState, + gitHubInfo: { + ...oldState.gitHubInfo, + repoSearch, + }, + }; + }); + }} + type="text" + /> +
  • + ) : null} + {gitHubSearchOpen + ? (this.state.gitHubInfo?.availableRepositories || []) + .map((repoInfo: GitHubRepositoryIdentifier) => { + const nameWithOwner = `${repoInfo.owner}/${repoInfo.name}`; + + if ( + this.state.gitHubInfo?.repoSearch && + !nameWithOwner.match( + this.state.gitHubInfo?.repoSearch, + ) + ) { + return null; + } + + return ( +
  • { + const branchName = prompt( + 'Which branch should we push to?', + 'main', + ); + + if (!branchName?.trim()) { + return; + } + + this.setTargetRepo( + repoInfo.owner, + repoInfo.name, + branchName, + ); + }}> + {nameWithOwner} +
  • + ); + }) + .filter(Boolean) + : null} +
    + ) : null} +
    {codesandboxResult ? (
    {codesandboxResult.type === 'loading' ? ( @@ -637,6 +1922,22 @@ class CodeExporter extends Component { )}
    ) : null} + {gitHubPushResult ? ( +
    + {gitHubPushResult.type === 'loading' ? ( + 'Loading...' + ) : gitHubPushResult.type === 'error' ? ( + `Error: ${gitHubPushResult.error}` + ) : ( + + Files synchronized + + )} +
    + ) : null}
    ) : null}
    @@ -690,21 +1991,150 @@ class CodeExporter extends Component { borderTop: '1px solid rgb(220, 220, 220)', fontSize: 12, }}> - {codeSnippet ? ( - - ) : ( -
    - The query is invalid. -
    - The generated code will appear here once the errors in the query - editor are resolved. -
    - )} + {!this.state.gitHubConfirmationModal ? ( + codeSnippet ? ( + + ) : ( +
    + The query is invalid. +
    + The generated code will appear here once the errors in the query + editor are resolved. +
    + ) + ) : null}
    + {!!this.state.gitHubConfirmationModal ? ( + +
    +
    File change summary
    +
    +
    +
    +

    + These files have changed in{' '} + + {this.state.gitHubInfo.targetRepo.owner}/ + {this.state.gitHubInfo.targetRepo.name} + {' '} + on the{' '} + {this.state.gitHubInfo.targetRepo.branch}{' '} + branch and will be overwritten +

    +
    + {( + this.state.gitHubConfirmationModal?.changeset + ?.changed || [] + ).map((file) => { + const checked = + this.state.snippetStorage?.overwriteSettings?.[ + file.path + ] ?? true; + + return ( + <> + + - {checked ? 'overwrite' : 'skip'} + + ); + })} + {(this.state.gitHubConfirmationModal?.changeset.new || []) + .length > 0 ? ( + <> +

    New files

    + + {( + this.state.gitHubConfirmationModal?.changeset + ?.new || [] + ).map((file) => { + return ( + <> + + {file.path} + + + + ); + })} + + ) : null} + {( + this.state.gitHubConfirmationModal?.changeset + .unchanged || [] + ).length > 0 ? ( + <> +

    Unchanged files

    + + {( + this.state.gitHubConfirmationModal?.changeset + ?.unchanged || [] + ).map((file) => { + return ( + <> + + {file.path} + + + + ); + })} + + ) : null} +
    +
    +
    +
    +
    + + +
    +
    +
    + ) : null}
    ); } @@ -785,7 +2215,7 @@ export default function CodeExporterWrapper({
    diff --git a/src/Modal.js b/src/Modal.js new file mode 100644 index 0000000..ec739cf --- /dev/null +++ b/src/Modal.js @@ -0,0 +1,29 @@ +import React from 'react'; + +const css = { + modal: { + position: 'absolute', + top: '56px', + left: '0', + width: '100%', + background: 'rgba(0, 0, 0, 0.6)', + zIndex: '99', + }, + modalMain: { + position: 'absolute', + background: 'white', + width: '100%', + }, + displayBlock: {display: 'block'}, + displayNone: {display: 'none'}, +}; + +const Modal = ({show, children}) => { + return ( +
    +
    {children}
    +
    + ); +}; + +export default Modal; diff --git a/src/OneGraphOperations.js b/src/OneGraphOperations.js new file mode 100644 index 0000000..8722e88 --- /dev/null +++ b/src/OneGraphOperations.js @@ -0,0 +1,463 @@ +// @flow +import sha1 from 'js-sha1'; + +const encoder = new TextEncoder(); + +const computeGitHash = (source) => + sha1('blob ' + encoder.encode(source).length + '\0' + source); + +// This setup is only needed once per application +async function fetchOneGraph(operationsDoc, operationName, variables) { + const result = await fetch( + 'https://serve.onegraph.io/graphql?app_id=af3246eb-92a6-4dc6-ac78-e1b3d0c31212', + { + method: 'POST', + headers: { + Authorization: 'Bearer _7_g7CP7XMws-Wy5IbGmIu7RmI3jo5E6lpEmtYItVW0', + }, + body: JSON.stringify({ + query: operationsDoc, + variables: variables, + operationName: operationName, + }), + }, + ); + + return await result.json(); +} + +export function fetchFindMeOnGitHub() { + return fetchOneGraph( + ` +query FindMeOnGitHub { + me { + github { + id + login + repositories( + first: 100 + orderBy: { field: CREATED_AT, direction: DESC } + affiliations: [ + OWNER + COLLABORATOR + ORGANIZATION_MEMBER + ] + ownerAffiliations: [ + OWNER + COLLABORATOR + ORGANIZATION_MEMBER + ] + ) { + edges { + node { + id + nameWithOwner + } + } + totalCount + } + } + } +}`, + 'FindMeOnGitHub', + {}, + ); +} + +const operationsDoc = ` + mutation CreateTree($path: String!, $treeJson: JSON!) { + gitHub { + makeRestCall { + post( + path: $path + jsonBody: $treeJson + contentType: "application/json" + accept: "application/json" + ) { + response { + statusCode + } + jsonBody + } + } + } +} + +fragment GitHubRefFragment on GitHubRef { + id + name + target { + id + oid + ... on GitHubCommit { + history(first: 1) { + edges { + node { + tree { + entries { + name + path + oid + object { + ... on GitHubTree { + id + entries { + name + path + oid + } + } + } + } + } + } + } + } + tree { + id + oid + } + } + } +} + +query DefaultBranchRef($owner: String!, $name: String!) { + gitHub { + repository(name: $name, owner: $owner) { + id + defaultBranchRef { + ...GitHubRefFragment + } + } + } +} + +query FilesOnRef($owner: String!, $name: String!, $fullyQualifiedRefName: String!) { + gitHub { + repository(name: $name, owner: $owner) { + id + ref(qualifiedName: $fullyQualifiedRefName) { + ...GitHubRefFragment + } + } + } +} + +mutation CreateRepo($repoJson: JSON!) { + gitHub { + makeRestCall { + post( + path: "/user/repos" + jsonBody: $repoJson + contentType: "application/json" + accept: "application/json" + ) { + response { + statusCode + } + jsonBody + } + } + } +} + +mutation CreateCommit($path: String!, $commitJson: JSON!) { + gitHub { + makeRestCall { + post(path: $path, jsonBody: $commitJson) { + response { + statusCode + } + jsonBody + } + } + } +} + +mutation CreateRef( + $repositoryId: ID! + $name: String! + $oid: GitHubGitObjectID! +) { + gitHub { + createRef( + input: { + repositoryId: $repositoryId + name: $name + oid: $oid + } + ) { + ref { + ...GitHubRefFragment + } + } + } +} + +mutation UpdateRef($refId: ID!, $sha: GitHubGitObjectID!) { + gitHub { + updateRef(input: { refId: $refId, oid: $sha }) { + clientMutationId + ref { + name + id + target { + oid + id + } + } + } + } +} +`; + +export function executeCreateTree(owner, name, treeJson) { + const path = `/repos/${owner}/${name}/git/trees`; + return fetchOneGraph(operationsDoc, 'CreateTree', { + path: path, + treeJson: treeJson, + }); +} + +export function fetchDefaultBranchRef(owner, name) { + return fetchOneGraph(operationsDoc, 'DefaultBranchRef', { + owner: owner, + name: name, + }); +} + +export function fetchFilesOnRef(owner, name, fullyQualifiedRefName) { + return fetchOneGraph(operationsDoc, 'FilesOnRef', { + owner: owner, + name: name, + fullyQualifiedRefName: fullyQualifiedRefName, + }); +} + +export function executeCreateRepo(name: string) { + return fetchOneGraph(operationsDoc, 'CreateRepo', { + repoJson: {name: name, auto_init: true}, + }); +} + +export function executeCreateCommit(owner, name, commitJson) { + const path = `/repos/${owner}/${name}/git/commits`; + return fetchOneGraph(operationsDoc, 'CreateCommit', { + path: path, + commitJson: commitJson, + }); +} + +export function executeUpdateRef(refId, sha) { + return fetchOneGraph(operationsDoc, 'UpdateRef', {refId, sha}); +} + +export function executeCreateRef({repositoryId, name, oid}) { + return fetchOneGraph(operationsDoc, 'CreateRef', {repositoryId, name, oid}); +} + +type TreeFiles = { + [string]: {| + content: string | Object, + |}, +}; + +export const pushFilesToBranch = async function ({ + owner, + name, + message, + branch, + treeFiles: rawTreeFiles, + acceptOverrides, +}: { + owner: string, + name: string, + branch: string, + message: string, + treeFiles: TreeFiles, + acceptOverrides: ?boolean, +}): Promise< + | {ok: 'empty-changeset'} + | { + ok: 'success', + result: any, + treeJson: any, + treeResults: any, + commitJson: any, + commitResult: any, + updateRefResult: any, + } + | {confirmationNeeded: string, changeset: any, originalTreeFiles: TreeFiles} + | {error: string} + | null, +> { + const fileHashes = rawTreeFiles.reduce((acc, next) => { + acc[next.path] = computeGitHash(next.content); + return acc; + }, {}); + + const result = await fetchDefaultBranchRef(owner, name); + const repositoryId = result?.data?.gitHub?.repository?.id; + const defaultBranchRef = result?.data?.gitHub?.repository?.defaultBranchRef; + const defaultBranchRefName = + result?.data?.gitHub?.repository?.defaultBranchRef?.name; + + // By default we're going to base our commit off of the latest default branch head + let headRefNodeId = defaultBranchRef?.id; + let headRefCommitSha = defaultBranchRef?.target?.oid; + let headRefTreeSha = defaultBranchRef?.target?.tree?.oid; + let headRefTreeNodeId = defaultBranchRef?.target?.tree?.id; + + let existingFiles = + defaultBranchRef?.target?.history?.edges?.[0]?.node?.tree?.entries || []; + + const pushingToNonDefaultBranch = branch !== defaultBranchRefName; + + const fullyQualifiedRefName = `refs/heads/${branch}`; + + // But if we're pushing to a non-default branch, we should check to see if the branch exists, and if so, update our assumptions + if (pushingToNonDefaultBranch) { + let filesOnRefResult = await fetchFilesOnRef( + owner, + name, + fullyQualifiedRefName, + ); + let branchRef = filesOnRefResult?.data?.gitHub?.repository?.ref; + let branchRefName = branchRef?.name; + + if (!branchRef) { + // If the branchRef doesn't exist, then we create a new ref pointing to the head default ref + const createRefResult = await executeCreateRef({ + repositoryId, + name: fullyQualifiedRefName, + oid: headRefCommitSha, + }); + + branchRef = createRefResult?.data?.gitHub?.createRef?.ref; + if (!branchRef) { + return {error: `Failed to create branch '${branch}'`}; + } + } + + branchRefName = branchRef?.name; + existingFiles = + branchRef?.target?.history?.edges?.[0]?.node?.tree?.entries || []; + + headRefNodeId = branchRef?.id; + headRefCommitSha = branchRef?.target?.oid; + headRefTreeSha = branchRef?.target?.tree?.oid; + headRefTreeNodeId = branchRef?.target?.tree?.id; + } + + const findExistingFileByPath = (path) => { + const parts = path.split('/'); + let candidates = existingFiles; + + const helper = (parts) => { + const next = parts[0]; + const remainingParts = parts.slice(1); + const nextFile = candidates.find((gitFile) => gitFile.name === next); + + if (!nextFile) return null; + + if (remainingParts.length === 0) { + return nextFile; + } + + candidates = nextFile.object?.entries || []; + return helper(remainingParts); + }; + + return helper(parts); + }; + + // Try to calculate the minimum number of files we can upload + const changeset = rawTreeFiles.reduce( + (acc, file) => { + // This will only look two levels down since that's the limit of our GraphQL query + const existingFile = findExistingFileByPath(file.path); + if (!existingFile) { + acc['new'] = [...acc.new, file]; + return acc; + } + + // This file already exists, so check if the hash is the same + if (fileHashes[file.path] === existingFile.oid) { + const tempFile = { + ...file, + }; + + delete tempFile['content']; + + acc['unchanged'] = [ + ...acc.unchanged, + {...tempFile, sha: fileHashes[file.path]}, + ]; + return acc; + } + + // The file exists, but its hash has changed; + acc['changed'] = [...acc.changed, file]; + return acc; + }, + {unchanged: [], new: [], changed: []}, + ); + + // Don't bother uploading files with unchanged hashes (Git will filter these out of a changeset anyway) + const treeFiles = [...changeset.new, ...changeset.changed]; + + if (treeFiles.length === 0) { + return {ok: 'empty-changeset'}; + } + + if ((changeset.changed || []).length > 0 && !acceptOverrides) { + return { + confirmationNeeded: 'Some files have changed and will be overwritten', + changeset, + originalTreeFiles: rawTreeFiles, + }; + } + + const treeJson = { + base_tree: headRefTreeSha, + tree: treeFiles, + }; + + if (!headRefTreeSha) return {error: 'Failed to find sha of head ref tree'}; + + const treeResults = await executeCreateTree(owner, name, treeJson); + const newTreeSha = + treeResults?.data?.gitHub?.makeRestCall?.post?.jsonBody?.sha; + + const commitJson = { + message: message, + tree: newTreeSha, + parents: [headRefCommitSha], + }; + if (!newTreeSha || !headRefCommitSha) { + return { + error: 'Failed to find git tree sha', + }; + } + + const commitResult = await executeCreateCommit(owner, name, commitJson); + const commitRefId = + commitResult?.data?.gitHub?.makeRestCall?.post?.jsonBody?.node_id; + const commitSha = + commitResult?.data?.gitHub?.makeRestCall?.post?.jsonBody?.sha; + + if (!commitRefId || !commitSha) + return {error: 'Failed to find appropriate commit sha'}; + + const updateRefResult = await executeUpdateRef(headRefNodeId, commitSha); + + return { + ok: 'success', + result, + treeJson, + treeResults, + commitJson, + commitResult, + updateRefResult, + }; +}; diff --git a/src/index.js b/src/index.js index 8a34458..9a2bbab 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,19 @@ // @flow -import CodeExporter, {computeOperationDataList} from './CodeExporter'; +import CodeExporter, { + computeOperationDataList, + astByNamedPath, + extractNodeToConnectionFragment, + namedPathOfAncestors, + pruneOperationToNamedPath, + renameOperation, + makeAstDirective, + makeArgumentsDefinitionsDirective, + makeArgumentsDirective, + updateAstAtPath, + findUnusedOperationVariables, + findFragmentVariables, +} from './CodeExporter'; import capitalizeFirstLetter from './utils/capitalizeFirstLetter'; import jsCommentsFactory from './utils/jsCommentsFactory'; import snippets from './snippets/index'; @@ -20,7 +33,18 @@ export { computeOperationDataList, capitalizeFirstLetter, jsCommentsFactory, + findUnusedOperationVariables, snippets, + astByNamedPath, + findFragmentVariables, + extractNodeToConnectionFragment, + namedPathOfAncestors, + pruneOperationToNamedPath, + renameOperation, + makeAstDirective, + updateAstAtPath, + makeArgumentsDefinitionsDirective, + makeArgumentsDirective, }; export default CodeExporter; diff --git a/src/toposort.js b/src/toposort.js index 882e0fc..4cae898 100644 --- a/src/toposort.js +++ b/src/toposort.js @@ -1,5 +1,4 @@ // @flow -import type {FragmentDefinitionNode, OperationDefinitionNode} from 'graphql'; import type {OperationData} from './CodeExporter.js'; type stringBoolMap = {[string]: boolean};