@restorecommerce/gql-bot 0.1.11 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,59 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.1.16](https://github.com/restorecommerce/libs/compare/@restorecommerce/gql-bot@0.1.15...@restorecommerce/gql-bot@0.1.16) (2022-02-14)
7
+
8
+ **Note:** Version bump only for package @restorecommerce/gql-bot
9
+
10
+
11
+
12
+
13
+
14
+ ## [0.1.15](https://github.com/restorecommerce/libs/compare/@restorecommerce/gql-bot@0.1.14...@restorecommerce/gql-bot@0.1.15) (2021-08-23)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **version:** up version to be in sync in package-lock ([b8f22c1](https://github.com/restorecommerce/libs/commit/b8f22c1268ee2af4beff7d88bda30f197896e3d2))
20
+
21
+
22
+
23
+
24
+
25
+ ## [0.1.14](https://github.com/restorecommerce/libs/compare/@restorecommerce/gql-bot@0.1.13...@restorecommerce/gql-bot@0.1.14) (2021-08-10)
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **acs-client, gql-bot, kafka-client, koa-health-check:** eslintrc added root to uniquely identify eslint plugin package to avoid error building facade-srv ([c2e446b](https://github.com/restorecommerce/libs/commit/c2e446bf0f09d7fa4f000da3bb09fd612cb9526c))
31
+
32
+
33
+
34
+
35
+
36
+ ## [0.1.13](https://github.com/restorecommerce/libs/compare/@restorecommerce/gql-bot@0.1.12...@restorecommerce/gql-bot@0.1.13) (2021-08-03)
37
+
38
+
39
+ ### Bug Fixes
40
+
41
+ * up pkg locks ([8ed92d6](https://github.com/restorecommerce/libs/commit/8ed92d613b9a095e4b5066056ac566e5dbcf1472))
42
+ * updated githead ([2904d30](https://github.com/restorecommerce/libs/commit/2904d30e5773dc8a87c01a08ff6481f99d692354))
43
+
44
+
45
+
46
+
47
+
48
+ ## [0.1.12](https://github.com/restorecommerce/libs/compare/@restorecommerce/gql-bot@0.1.11...@restorecommerce/gql-bot@0.1.12) (2021-08-03)
49
+
50
+
51
+ ### Bug Fixes
52
+
53
+ * **koa-health-check:** added missing .eslintrc.js ([45af632](https://github.com/restorecommerce/libs/commit/45af632955d2dd448e7a27f4e8c4b971412cd004))
54
+
55
+
56
+
57
+
58
+
6
59
  ## [0.1.11](https://github.com/restorecommerce/libs/compare/@restorecommerce/gql-bot@0.1.10...@restorecommerce/gql-bot@0.1.11) (2021-06-26)
7
60
 
8
61
  **Note:** Version bump only for package @restorecommerce/gql-bot
package/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) Invend GmbH and other contributors.
1
+ Copyright (c) n-fuse GmbH and other contributors.
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining a copy of
4
4
  this software and associated documentation files (the "Software"), to deal in
@@ -16,4 +16,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
16
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
17
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
18
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
- SOFTWARE.
19
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ export declare class Client {
2
+ opts: any;
3
+ metaQs: any;
4
+ entryBaseUrl: string;
5
+ constructor(opts: any);
6
+ _buildURLs(): any;
7
+ _normalizeUrl(source?: any): string;
8
+ post(source: any, job?: any, accessControl?: any, formOptions?: any): Promise<any>;
9
+ }
package/lib/client.js ADDED
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.Client = void 0;
13
+ const _ = require("lodash");
14
+ const url = require("url");
15
+ const fs = require("fs");
16
+ const graphql_tag_1 = require("graphql-tag");
17
+ const apollo_client_1 = require("apollo-client");
18
+ const apollo_cache_inmemory_1 = require("apollo-cache-inmemory");
19
+ const node_fetch_1 = require("node-fetch"); // required for apollo-link-http
20
+ const apollo_link_http_1 = require("apollo-link-http");
21
+ function _checkVariableMutation(mutation) {
22
+ const mutationName = mutation.slice(mutation.indexOf(' '), mutation.indexOf('($'));
23
+ if (mutationName.indexOf('$') > 0) {
24
+ return false;
25
+ }
26
+ else {
27
+ return new RegExp('\\b' + mutationName + '\\(', 'i').test(mutation);
28
+ }
29
+ }
30
+ function _replaceInlineVars(mutation, args) {
31
+ if (mutation)
32
+ return mutation.replace(/\${(\w+)}/g, (_, v) => args[v]);
33
+ }
34
+ function _createQueryVariables(inputVarName, queryVarKey, varValue) {
35
+ if (queryVarKey) {
36
+ return {
37
+ [inputVarName]: {
38
+ [queryVarKey]: JSON.parse(varValue)
39
+ }
40
+ };
41
+ }
42
+ return {
43
+ [inputVarName]: JSON.parse(varValue)
44
+ };
45
+ }
46
+ class Client {
47
+ constructor(opts) {
48
+ if (_.isNil(opts)) {
49
+ throw new Error('Missing options parameter');
50
+ }
51
+ this.metaQs = { meta: true };
52
+ _.defaults(opts, {
53
+ entry: null,
54
+ protocol: 'http',
55
+ apiKey: null,
56
+ headers: {}
57
+ });
58
+ const required = ['entry'];
59
+ required.forEach((key) => {
60
+ if (!opts[key]) {
61
+ throw new Error('Missing option: \'' + key + '\'');
62
+ }
63
+ });
64
+ this.opts = opts;
65
+ this._buildURLs();
66
+ _.assign(this.opts.headers, {
67
+ Accept: 'application/json',
68
+ Origin: this.entryBaseUrl
69
+ });
70
+ }
71
+ _buildURLs() {
72
+ const entry = this.opts.entry;
73
+ if (!String(entry).startsWith('http')) {
74
+ this.opts.entry = this.opts.protocol + '://' + entry;
75
+ }
76
+ const parsedEntry = url.parse(entry);
77
+ if (parsedEntry.protocol) {
78
+ this.opts.protocol = parsedEntry.protocol;
79
+ }
80
+ const protocol = this.opts.protocol + '//';
81
+ this.entryBaseUrl = protocol + parsedEntry.host + parsedEntry.path;
82
+ }
83
+ _normalizeUrl(source) {
84
+ let extendURL = '';
85
+ if (source) {
86
+ // this is source.path /restore/oss-client/test/folder/file.1
87
+ if (!source.path && source != true) {
88
+ extendURL = source;
89
+ }
90
+ if (source.path) {
91
+ // TODO: read this 'the Node way'
92
+ extendURL = fs.readFileSync(source.path).toString();
93
+ extendURL = '?query=' + extendURL;
94
+ }
95
+ }
96
+ return url.resolve(this.entryBaseUrl, extendURL);
97
+ }
98
+ post(source, job, accessControl, formOptions) {
99
+ return __awaiter(this, void 0, void 0, function* () {
100
+ const normalUrl = this._normalizeUrl();
101
+ let mutation;
102
+ if (job && job.mutation) {
103
+ mutation = JSON.stringify(job.mutation);
104
+ }
105
+ else {
106
+ throw new Error('mutation not present in job config');
107
+ }
108
+ const apiKey = JSON.stringify(this.opts.apiKey);
109
+ let resource_list = JSON.stringify(source);
110
+ if (mutation) {
111
+ // don't replace quoted strings inside outer quotes
112
+ // (i.e. if the quote is preceded by a backslash)
113
+ // make sure to also match line/expression start
114
+ // and keep the symbol preceding the quote
115
+ mutation = mutation.replace(/(^|[^\\])\"/g, '$1');
116
+ // afterwards, replace escaped quotes with regular ones
117
+ mutation = mutation.replace(/\\\"/g, '\"');
118
+ }
119
+ let variables;
120
+ if (_checkVariableMutation(mutation)) {
121
+ const queryVarKey = job.queryVariables;
122
+ const inputVarName = mutation.slice(mutation.indexOf('$') + 1, mutation.indexOf(':'));
123
+ variables = _createQueryVariables(inputVarName, queryVarKey, resource_list);
124
+ }
125
+ else {
126
+ // To remove double quotes from the keys in JSON data
127
+ resource_list = resource_list.replace(/\"([^(\")"]+)\":/g, '$1:');
128
+ mutation = _replaceInlineVars(mutation, { resource_list, apiKey });
129
+ }
130
+ const apolloLinkOpts = {
131
+ uri: normalUrl,
132
+ fetch: node_fetch_1.default
133
+ };
134
+ if (this.opts.headers) {
135
+ apolloLinkOpts['headers'] = this.opts.headers;
136
+ }
137
+ let apolloLink = apollo_link_http_1.createHttpLink(apolloLinkOpts);
138
+ const apolloCache = new apollo_cache_inmemory_1.InMemoryCache();
139
+ const apolloClient = new apollo_client_1.ApolloClient({
140
+ cache: apolloCache,
141
+ link: apolloLink
142
+ });
143
+ return apolloClient.mutate({
144
+ mutation: graphql_tag_1.default `${mutation}`,
145
+ variables
146
+ });
147
+ });
148
+ }
149
+ }
150
+ exports.Client = Client;
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { Client } from './client';
2
+ export { Client };
3
+ import { GraphQLProcessor } from './job_processor_gql';
4
+ export { GraphQLProcessor };
5
+ import { JobProcessor, Job } from './job_processor';
6
+ export { JobProcessor, Job };
package/lib/index.js ADDED
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Job = exports.JobProcessor = exports.GraphQLProcessor = exports.Client = void 0;
4
+ const client_1 = require("./client");
5
+ Object.defineProperty(exports, "Client", { enumerable: true, get: function () { return client_1.Client; } });
6
+ const job_processor_gql_1 = require("./job_processor_gql");
7
+ Object.defineProperty(exports, "GraphQLProcessor", { enumerable: true, get: function () { return job_processor_gql_1.GraphQLProcessor; } });
8
+ const job_processor_1 = require("./job_processor");
9
+ Object.defineProperty(exports, "JobProcessor", { enumerable: true, get: function () { return job_processor_1.JobProcessor; } });
10
+ Object.defineProperty(exports, "Job", { enumerable: true, get: function () { return job_processor_1.Job; } });
@@ -0,0 +1,21 @@
1
+ /// <reference types="node" />
2
+ import { Readable } from 'stream';
3
+ import { EventEmitter } from 'events';
4
+ export declare class ReadArrayStream extends Readable {
5
+ array: any[];
6
+ constructor(opts: any, array: any[]);
7
+ _read(): void;
8
+ }
9
+ export declare class Job extends EventEmitter {
10
+ opts: any;
11
+ constructor(opts?: any);
12
+ }
13
+ export declare class JobProcessor {
14
+ jobInfo: any;
15
+ processed: number;
16
+ processedTasks: number;
17
+ taskStream: any;
18
+ constructor(jobInfo: any);
19
+ start(tasks?: any, job?: Job): Promise<any>;
20
+ sync(task: any, job: Job): Promise<any>;
21
+ }
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.JobProcessor = exports.Job = exports.ReadArrayStream = void 0;
13
+ const _ = require("lodash");
14
+ const ps = require("promise-streams");
15
+ const stream_1 = require("stream");
16
+ const through2 = require("through2");
17
+ const readdirp = require("readdirp");
18
+ const path = require("path");
19
+ const events_1 = require("events");
20
+ class ReadArrayStream extends stream_1.Readable {
21
+ constructor(opts, array) {
22
+ super(opts);
23
+ this.array = _.clone(array);
24
+ }
25
+ _read() {
26
+ if (this.array && this.array.length > 0) {
27
+ const data = this.array.shift();
28
+ this.push(data);
29
+ }
30
+ else {
31
+ this.push(null);
32
+ }
33
+ }
34
+ }
35
+ exports.ReadArrayStream = ReadArrayStream;
36
+ /*
37
+ * A class to represent a job and its end result as promise.
38
+ * It also reports the progress as event emitter.
39
+ */
40
+ class Job extends events_1.EventEmitter {
41
+ constructor(opts) {
42
+ super();
43
+ this.setMaxListeners(100);
44
+ this.opts = opts || {};
45
+ }
46
+ }
47
+ exports.Job = Job;
48
+ class JobProcessor {
49
+ constructor(jobInfo) {
50
+ this.jobInfo = jobInfo;
51
+ this.processed = 0;
52
+ _.defaults(this.jobInfo, {
53
+ concurrency: 3,
54
+ processor: null
55
+ });
56
+ }
57
+ start(tasks, job) {
58
+ return __awaiter(this, void 0, void 0, function* () {
59
+ job = job || new Job();
60
+ tasks = tasks || this.jobInfo.tasks;
61
+ const concurrency = this.jobInfo.options.concurrency;
62
+ this.taskStream = ps.map({ concurrent: concurrency }, (task) => {
63
+ return this.jobInfo.options.processor.process(task).then((body) => {
64
+ console.log('Processed task [' + this.processed + '] and status is:' + JSON.stringify(body));
65
+ this.processed++;
66
+ task.inputTask.processing--;
67
+ task.progress.value = 100; // task complete
68
+ job.emit('progress', task);
69
+ });
70
+ });
71
+ this.taskStream.setMaxListeners(100);
72
+ const inputTaskStream = new ReadArrayStream({
73
+ objectMode: true
74
+ }, tasks);
75
+ inputTaskStream.pipe(through2.obj((task, enc, cb) => __awaiter(this, void 0, void 0, function* () {
76
+ const operation = task.operation;
77
+ yield this[operation].apply(this, [task, job]);
78
+ cb();
79
+ })));
80
+ yield ps.wait(inputTaskStream);
81
+ this.taskStream.on('error', (err) => { throw err; });
82
+ this.taskStream.on('end', () => {
83
+ // console.log('Stream ended');
84
+ });
85
+ const tasksStreamEnded = ps.wait(this.taskStream);
86
+ // Wait until the task stream emitted 'end'
87
+ tasksStreamEnded.then(() => {
88
+ job.emit('done');
89
+ });
90
+ return job;
91
+ });
92
+ }
93
+ sync(task, job) {
94
+ return __awaiter(this, void 0, void 0, function* () {
95
+ const pathOptions = {
96
+ fileFilter: (entry) => { return true; },
97
+ depth: 1,
98
+ lstat: true
99
+ };
100
+ const pathSegments = [process.cwd()];
101
+ if (!_.isUndefined(this.jobInfo.base)) {
102
+ pathSegments.push(this.jobInfo.base);
103
+ }
104
+ if (!_.isUndefined(task.src)) {
105
+ pathSegments.push(task.src);
106
+ }
107
+ pathOptions.fileFilter = task.filter || pathOptions.fileFilter;
108
+ if (task.depth) {
109
+ pathOptions.depth = task.depth;
110
+ }
111
+ else {
112
+ delete pathOptions.depth;
113
+ }
114
+ const fileItemStream = readdirp(path.join.apply(this, pathSegments), pathOptions);
115
+ yield new Promise((resolve, reject) => {
116
+ fileItemStream
117
+ .on('warn', (warn) => {
118
+ job.emit('warn', warn);
119
+ })
120
+ .on('error', (err) => {
121
+ job.emit('error', err);
122
+ })
123
+ .on('data', (fileItem) => {
124
+ fileItem.progress = {
125
+ value: 0
126
+ };
127
+ _.merge(fileItem, task);
128
+ if (!task.processing) {
129
+ task.processing = 0;
130
+ }
131
+ task.processing++;
132
+ fileItem.inputTask = task;
133
+ job.emit('progress', fileItem);
134
+ resolve(job);
135
+ })
136
+ .on('end', () => {
137
+ if (!this.processedTasks) {
138
+ this.processedTasks = 0;
139
+ }
140
+ this.processedTasks++;
141
+ // Check processedTasks tasks
142
+ // Manually emit `end` event for all tasks finished
143
+ if (this.processedTasks === this.jobInfo.tasks.length) {
144
+ this.taskStream._flush(() => { });
145
+ this.taskStream.emit('end');
146
+ }
147
+ })
148
+ .pipe(this.taskStream, { end: false });
149
+ });
150
+ });
151
+ }
152
+ }
153
+ exports.JobProcessor = JobProcessor;
@@ -0,0 +1,10 @@
1
+ import { Client } from './index';
2
+ /**
3
+ * GraphQL-specific job processor.
4
+ */
5
+ export declare class GraphQLProcessor {
6
+ opts: any;
7
+ client: Client;
8
+ constructor(opts: any);
9
+ process(job: any): Promise<any>;
10
+ }
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.GraphQLProcessor = void 0;
13
+ const _ = require("lodash");
14
+ const fs = require("fs");
15
+ const index_1 = require("./index");
16
+ const { YamlStreamReadTransformer } = require('yaml-document-stream');
17
+ /**
18
+ * GraphQL-specific job processor.
19
+ */
20
+ class GraphQLProcessor {
21
+ constructor(opts) {
22
+ if (_.isNil(opts)) {
23
+ throw new Error('Missing options parameter');
24
+ }
25
+ const defaults = {
26
+ diffBase: 'md5'
27
+ };
28
+ _.defaults(opts, defaults);
29
+ this.opts = opts;
30
+ this.client = new index_1.Client(opts);
31
+ }
32
+ process(job) {
33
+ return __awaiter(this, void 0, void 0, function* () {
34
+ let yamlStream = new YamlStreamReadTransformer();
35
+ let jobPath = job.path;
36
+ let data = false;
37
+ switch (job.operation) {
38
+ case 'sync': { // synchronous operation
39
+ const fileStream = fs.createReadStream(job.fullPath);
40
+ fileStream.pipe(yamlStream);
41
+ let batchsize;
42
+ if (job.batchSize) {
43
+ batchsize = job.batchSize;
44
+ console.log('Batch size:', batchsize);
45
+ }
46
+ let counter = 0;
47
+ let batchCounter = 0;
48
+ let docArr = [];
49
+ let resultArr = [];
50
+ // Here we read from the readable stream each yaml document parsed
51
+ // as an object, and if we have batching enabled we first batch this
52
+ // documents into smaller sets. When a dataset is ready to be imported,
53
+ // we pause the readable stream and emit a 'pause' event and
54
+ // right after that we reset the dataset object 'docArr'.
55
+ yamlStream.on('data', (doc) => {
56
+ data = true;
57
+ if (batchsize && batchsize != undefined) {
58
+ docArr.push(doc);
59
+ counter++;
60
+ if (counter === batchsize) {
61
+ counter = 0;
62
+ batchCounter++;
63
+ console.log('Processing batch:', batchCounter);
64
+ yamlStream.pause();
65
+ docArr = [];
66
+ }
67
+ }
68
+ else {
69
+ docArr.push(doc);
70
+ }
71
+ });
72
+ // On 'pause' event we create a post request using the GQL Client
73
+ // using the accumulated resources inside the 'docArr' dataset,
74
+ // we wait for the response, store the response inside an array and
75
+ // only then we emit a 'resume' event, resuming reading from the
76
+ // 'yamlStream' readable stream.
77
+ yamlStream.on('pause', () => __awaiter(this, void 0, void 0, function* () {
78
+ let result = yield this.client.post(docArr, job);
79
+ resultArr.push(result);
80
+ yamlStream.resume();
81
+ }));
82
+ // On 'end' if we still have data accumulated inside the 'docArr'
83
+ // dataset, we create a final post request to import this data as-well,
84
+ // store the response inside the array and finally resolve this as a
85
+ // Promise to return all the responses back to the initial caller.
86
+ return new Promise((resolve, reject) => {
87
+ yamlStream.on('end', () => __awaiter(this, void 0, void 0, function* () {
88
+ if (data === false) {
89
+ throw new Error(`Could not import resources from ${jobPath}. Readable stream is empty. Please provide a file with YAML multi-document format.`);
90
+ }
91
+ if (docArr && !_.isEmpty(docArr)) {
92
+ batchCounter++;
93
+ console.log('Processing batch:', batchCounter);
94
+ let result = yield this.client.post(docArr, job);
95
+ resultArr.push(result);
96
+ docArr = [];
97
+ resolve(resultArr);
98
+ }
99
+ }));
100
+ });
101
+ }
102
+ default: {
103
+ throw new Error('Unsupported job operation');
104
+ }
105
+ }
106
+ });
107
+ }
108
+ }
109
+ exports.GraphQLProcessor = GraphQLProcessor;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@restorecommerce/gql-bot",
3
3
  "description": "GraphQL Client Automated Task Processor",
4
4
  "main": "lib/index",
5
- "version": "0.1.11",
5
+ "version": "0.1.16",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/restorecommerce/libs.git"
@@ -59,5 +59,5 @@
59
59
  "engines": {
60
60
  "node": ">= 12.0.0"
61
61
  },
62
- "gitHead": "6f9b0654e7ac016896e05c176d3e2b312f57f9cd"
62
+ "gitHead": "e97bbfe2fe8166dfe1cd47ae60bce54347a4f1c9"
63
63
  }