1 | import chalk from "chalk";
|
2 | import {configObject} from "./config.js";
|
3 | import {cached} from "./decorators.js";
|
4 | import rp from "request-promise";
|
5 |
|
6 | global.chalk = chalk;
|
7 | global.log = (...text) => console.log(...text);
|
8 | global.write = (...text) => process.stdout.write(...text);
|
9 | global.elog = (...text) => console.log(...text);
|
10 | global.ewrite = (...text) => process.stderr.write(...text);
|
11 | global.errorLog = (...text) => log(...text.map(chalk.red));
|
12 |
|
13 | export class lib{
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 |
|
31 |
|
32 | static async makeAPIRequest({
|
33 | env, path, path_full, fullPath,
|
34 | payload, body, method = "GET",
|
35 | qs, headers = {},
|
36 | fullResponse = false, timeout = configObject.timeout || 20000
|
37 | }){
|
38 |
|
39 | if(fullPath) path_full = fullPath;
|
40 |
|
41 | let config = configObject?.api?.[env];
|
42 | if(!config) {
|
43 | throw new UnconfiguredEnvError(env);
|
44 | };
|
45 |
|
46 | if(method !== "GET" && !configObject.dangerModify){
|
47 | if(env === "UAT" && configObject.restrictUAT || env === "PROD"){
|
48 | throw new ProtectedEnvError(env);
|
49 | }
|
50 | }
|
51 |
|
52 | let rally_api_key = config.key;
|
53 | let rally_api = config.url;
|
54 | if(path && path.startsWith("/v1.0/")){
|
55 | rally_api = rally_api.replace("/api/v2", "/api");
|
56 | }
|
57 |
|
58 | path = path_full || rally_api + path;
|
59 | if(payload){
|
60 | body = JSON.stringify(payload, null, 4);
|
61 | }
|
62 |
|
63 | if(payload){
|
64 | headers["Content-Type"] = "application/vnd.api+json";
|
65 | }
|
66 |
|
67 | let fullHeaders = {
|
68 |
|
69 | Accept: "application/vnd.api+json",
|
70 | "X-SDVI-Client-Application": "Discovery-rtlib-" + (configObject.appName || "commandline"),
|
71 | ...headers,
|
72 | }
|
73 |
|
74 | if(configObject.vvverbose){
|
75 | log(`${method} @ ${path}`)
|
76 | log(JSON.stringify(fullHeaders, null, 4))
|
77 |
|
78 | if(body){
|
79 | log(body);
|
80 | }else{
|
81 | log("(No body")
|
82 | }
|
83 | }
|
84 |
|
85 | let requestOptions = {
|
86 | method, body, qs, uri: path,
|
87 | timeout,
|
88 | auth: {bearer: rally_api_key},
|
89 | headers: fullHeaders,
|
90 | simple: false, resolveWithFullResponse: true,
|
91 | };
|
92 |
|
93 | let response;
|
94 | try{
|
95 | response = await rp(requestOptions);
|
96 | if(configObject.vverbose || configObject.vvverbose){
|
97 | log(chalk`${method} @ ${response.request.uri.href}`);
|
98 | }
|
99 | }catch(e){
|
100 | if(e?.cause.code === "ESOCKETTIMEDOUT"){
|
101 | throw new APIError(response || {}, requestOptions, body);
|
102 | }else{
|
103 | throw e;
|
104 | }
|
105 | }
|
106 |
|
107 |
|
108 | if(!fullResponse && ![200, 201, 202, 203, 204].includes(response.statusCode)){
|
109 | throw new APIError(response, requestOptions, body);
|
110 | }
|
111 | let contentType = response.headers["content-type"];
|
112 | let isJSONResponse = contentType === "application/vnd.api+json" || contentType === "application/json";
|
113 |
|
114 | if(configObject.vvverbose){
|
115 | log(response.body);
|
116 | }
|
117 |
|
118 | if(fullResponse){
|
119 | return response;
|
120 | }else if(isJSONResponse){
|
121 | if([200, 201, 202, 203, 204].includes(response.statusCode) && !response?.body?.trim()) return {};
|
122 | try{
|
123 | return JSON.parse(response.body);
|
124 | }catch(e){
|
125 | log(response.body);
|
126 | throw new AbortError("Body is not valid json: ");
|
127 | }
|
128 | }else{
|
129 | return response.body;
|
130 | }
|
131 | }
|
132 |
|
133 |
|
134 |
|
135 |
|
136 |
|
137 |
|
138 | static async indexPath(env, path){
|
139 | let all = [];
|
140 |
|
141 | let opts = typeof env === "string" ? {env, path} : env;
|
142 | let json = await this.makeAPIRequest(opts);
|
143 |
|
144 | let [numPages, pageSize] = this.numPages(json.links.last);
|
145 |
|
146 |
|
147 | all = [...json.data];
|
148 | while(json.links.next){
|
149 | json = await this.makeAPIRequest({...opts, path_full: json.links.next});
|
150 | if(opts.observe) await opts.observe(json.data);
|
151 | all = [...all, ...json.data];
|
152 | }
|
153 |
|
154 | return all;
|
155 | }
|
156 |
|
157 |
|
158 | static numPages(str){
|
159 | return /page=(\d+)p(\d+)/.exec(str).slice(1);
|
160 | }
|
161 |
|
162 | static arrayChunk(array, chunkSize){
|
163 | let newArr = [];
|
164 | for (let i = 0; i < array.length; i += chunkSize){
|
165 | newArr.push(array.slice(i, i + chunkSize));
|
166 | }
|
167 | return newArr;
|
168 | }
|
169 |
|
170 | static async doPromises(promises, result = [], cb){
|
171 | for(let promise of promises){
|
172 | let res = await promise;
|
173 | result.push(res);
|
174 | if(cb){
|
175 | cb(res.data);
|
176 | }
|
177 | }
|
178 | return result
|
179 | }
|
180 |
|
181 | static clearProgress(size = 30){
|
182 | if(!configObject.globalProgress) return;
|
183 | process.stderr.write(`\r${" ".repeat(size + 15)}\r`);
|
184 | }
|
185 |
|
186 | static async drawProgress(i, max, size = process.stdout.columns - 15 || 15){
|
187 | if(!configObject.globalProgress) return;
|
188 | if(size > 45) size = 45;
|
189 | let pct = Number(i) / Number(max);
|
190 |
|
191 | pct = pct < 0 ? 0 : pct > 1 ? 1 : pct;
|
192 | let numFilled = Math.floor(pct * size);
|
193 | let numEmpty = size - numFilled;
|
194 |
|
195 | this.clearProgress(size);
|
196 | process.stderr.write(`[${"*".repeat(numFilled)}${" ".repeat(numEmpty)}] ${i} / ${max}`);
|
197 | }
|
198 |
|
199 |
|
200 | static async keepalive(func, inputData, {chunksize = 20, observe = async _=>_, progress = configObject.globalProgress} = {}){
|
201 | let total = inputData ? inputData.length : func.length;
|
202 | let i = 0;
|
203 | let createPromise = () => {
|
204 | let ret;
|
205 | if(i >= total) return [];
|
206 | if(inputData){
|
207 | ret = [i, func(inputData[i])];
|
208 | }else{
|
209 | ret = [i, func[i]()];
|
210 | }
|
211 |
|
212 | i++;
|
213 | return ret;
|
214 | }
|
215 |
|
216 | let values = [];
|
217 | let finished = 0;
|
218 | if(progress) process.stderr.write("\n");
|
219 | let threads = [...this.range(chunksize)].map(async whichThread => {
|
220 | while(true){
|
221 | let [i, currentPromise] = createPromise();
|
222 | if(i == undefined) break;
|
223 | values[i] = await observe(await currentPromise);
|
224 | if(progress) this.drawProgress(++finished, total);
|
225 | }
|
226 | });
|
227 | await Promise.all(threads);
|
228 | if(progress) process.stderr.write("\n");
|
229 |
|
230 |
|
231 | return values;
|
232 | }
|
233 |
|
234 | static *range(start, end){
|
235 | if(end === undefined){
|
236 | end = start;
|
237 | start = 0;
|
238 | }
|
239 | while(start < end) yield start++;
|
240 | }
|
241 |
|
242 |
|
243 |
|
244 |
|
245 |
|
246 |
|
247 |
|
248 |
|
249 |
|
250 |
|
251 |
|
252 |
|
253 | static async indexPathFast(env, path){
|
254 | let opts = typeof env === "string" ? {env, path} : env;
|
255 |
|
256 | let start = opts.start || 1;
|
257 | let initOpts = {...opts};
|
258 | if(opts.pageSize){
|
259 | initOpts.qs = {...opts.qs};
|
260 | initOpts.qs.page = `${start}p${opts.pageSize}`;
|
261 | }
|
262 |
|
263 | let json = await this.makeAPIRequest(initOpts);
|
264 |
|
265 | if(opts.observe && opts.start !== 1) json = await opts.observe(json);
|
266 |
|
267 | let baselink = json.links.first;
|
268 | const linkToPage = page => baselink.replace(`page=1p`, `page=${page}p`);
|
269 |
|
270 | let [numPages, pageSize] = this.numPages(json.links.last);
|
271 |
|
272 |
|
273 |
|
274 | let allResults = await this.keepalive(
|
275 | this.makeAPIRequest,
|
276 | [...this.range(start+1, Number(numPages) + 1 || opts.limit + 1)]
|
277 | .map(i => ({...opts, path_full: linkToPage(i)})),
|
278 | {chunksize: opts.chunksize, observe: opts.observe},
|
279 | );
|
280 | if(start == 1){
|
281 | allResults.unshift(json);
|
282 | }
|
283 | this.clearProgress();
|
284 |
|
285 | let all = [];
|
286 | for(let result of allResults){
|
287 | for(let item of result.data){
|
288 | all.push(item);
|
289 | }
|
290 | }
|
291 |
|
292 | return all;
|
293 | }
|
294 | static isLocalEnv(env){
|
295 | return !env || env === "LOCAL" || env === "LOC";
|
296 | }
|
297 | static envName(env){
|
298 | if(this.isLocalEnv(env)) return "LOCAL";
|
299 | return env;
|
300 | }
|
301 | };
|
302 |
|
303 | export class AbortError extends Error{
|
304 | constructor(message){
|
305 | super(message);
|
306 | Error.captureStackTrace(this, this.constructor);
|
307 | this.name = "AbortError";
|
308 | }
|
309 | }
|
310 |
|
311 | export class APIError extends Error{
|
312 | constructor(response, opts, body){
|
313 | super(chalk`
|
314 | {reset Request returned} {yellow ${response?.statusCode}}{
|
315 | {green ${JSON.stringify(opts, null, 4)}}
|
316 | {green ${body}}
|
317 | {reset ${response.body}}
|
318 | ===============================
|
319 | {red ${response.body ? "Request timed out" : "Bad response from API"}}
|
320 | ===============================
|
321 | `);
|
322 | this.response = response;
|
323 | this.opts = opts;
|
324 | this.body = body;
|
325 |
|
326 | Error.captureStackTrace(this, this.constructor);
|
327 | this.name = "ApiError";
|
328 | }
|
329 | }
|
330 |
|
331 | export class UnconfiguredEnvError extends AbortError{
|
332 | constructor(env){
|
333 | super("Unconfigured enviornment: " + env);
|
334 | this.name = "Unconfigured Env Error";
|
335 | }
|
336 | }
|
337 |
|
338 | export class ProtectedEnvError extends AbortError{
|
339 | constructor(env){
|
340 | super("Protected enviornment: " + env);
|
341 | this.name = "Protected Env Error";
|
342 | }
|
343 | }
|
344 |
|
345 | export class FileTooLargeError extends Error{
|
346 | constructor(file){
|
347 | super(`File ${file.parentAsset ? file.parentAsset.name : "(unknown)"}/${file.name} size is: ${file.sizeGB}g (> ~.2G)`);
|
348 | this.name = "File too large error";
|
349 | }
|
350 | }
|
351 |
|
352 | export class Collection{
|
353 | constructor(arr){
|
354 | this.arr = arr;
|
355 | }
|
356 | [Symbol.iterator](){
|
357 | return this.arr[Symbol.iterator]();
|
358 | }
|
359 | findById(id){
|
360 | return this.arr.find(x => x.id == id);
|
361 | }
|
362 | findByName(name){
|
363 | return this.arr.find(x => x.name == name);
|
364 | }
|
365 | findByNameContains(name){
|
366 | return this.arr.find(x => x.name.includes(name));
|
367 | }
|
368 | log(){
|
369 | for(let d of this){
|
370 | if(d){
|
371 | log(d.chalkPrint(true));
|
372 | }else{
|
373 | log(chalk`{red (None)}`);
|
374 | }
|
375 | }
|
376 | }
|
377 | get length(){return this.arr.length;}
|
378 | }
|
379 |
|
380 | export class RallyBase{
|
381 | static handleCaching(){
|
382 | if(!this.cache) this.cache = [];
|
383 | }
|
384 | static isLoaded(env){
|
385 | if(!this.hasLoadedAll) return;
|
386 | return this.hasLoadedAll[env];
|
387 | }
|
388 | static async getById(env, id, qs){
|
389 | this.handleCaching();
|
390 | for(let item of this.cache){
|
391 | if(item.id == id && item.remote === env || `${env}-${id}` === item.metastring) return item;
|
392 | }
|
393 |
|
394 | let data = await lib.makeAPIRequest({
|
395 | env, path: `/${this.endpoint}/${id}`,
|
396 | qs
|
397 | });
|
398 | if(data.data){
|
399 | let o = new this({data: data.data, remote: env, included: data.included});
|
400 | this.cache.push(o);
|
401 | return o;
|
402 | }
|
403 | }
|
404 |
|
405 | static async getByName(env, name, qs){
|
406 | this.handleCaching();
|
407 | for(let item of this.cache){
|
408 | if(item.name === name && item.remote === env) return item;
|
409 | }
|
410 |
|
411 | let data = await lib.makeAPIRequest({
|
412 | env, path: `/${this.endpoint}`,
|
413 | qs: {...qs, filter: `name=${name}` + (qs ? qs.filter : "")},
|
414 | });
|
415 |
|
416 | if(data.data[0]){
|
417 | let o = new this({data: data.data[0], remote: env, included: data.included});
|
418 | this.cache.push(o);
|
419 | return o;
|
420 | }
|
421 | }
|
422 |
|
423 | static async getAllPreCollect(d){return d;}
|
424 | static async getAll(env){
|
425 | this.handleCaching();
|
426 | let datas = await lib.indexPathFast({
|
427 | env, path: `/${this.endpoint}`,
|
428 | pageSize: "50",
|
429 | qs: {sort: "id"},
|
430 | });
|
431 | datas = await this.getAllPreCollect(datas);
|
432 | let all = new Collection(datas.map(data => new this({data, remote: env})));
|
433 | this.cache = [...this.cache, ...all.arr];
|
434 | return all;
|
435 | }
|
436 | static async removeCache(env){
|
437 | this.handleCaching();
|
438 | this.cache = this.cache.filter(x => x.remote !== env);
|
439 | }
|
440 |
|
441 |
|
442 |
|
443 | async resolveApply(type, dataObj, direction){
|
444 | let obj;
|
445 | if(direction == "generic"){
|
446 | obj = await type.getById(this.remote, dataObj.id);
|
447 | if(obj){
|
448 | dataObj.name = obj.name
|
449 | }
|
450 | }else if(direction == "specific"){
|
451 | obj = await type.getByName(this.remote, dataObj.name);
|
452 | if(obj){
|
453 | dataObj.id = obj.id
|
454 | }
|
455 | }
|
456 | return obj;
|
457 | }
|
458 |
|
459 |
|
460 |
|
461 |
|
462 |
|
463 | async resolveField(type, name, isArray=false, direction="generic"){
|
464 |
|
465 | let field = this.relationships[name];
|
466 | if(!field?.data) return;
|
467 |
|
468 | if(isArray){
|
469 | return await Promise.all(field.data.map(o => this.resolveApply(type, o, direction)));
|
470 | }else{
|
471 | return await this.resolveApply(type, field.data, direction);
|
472 | }
|
473 | }
|
474 |
|
475 | cleanup(){
|
476 | for(let [key, val] of Object.entries(this.relationships)){
|
477 |
|
478 | if(val.data){
|
479 | if(val.data.id){
|
480 | delete val.data.id;
|
481 | }else if(val.data[0]){
|
482 | for(let x of val.data) delete x.id;
|
483 | }
|
484 | }
|
485 | delete val.links;
|
486 | }
|
487 |
|
488 | delete this.relationships.organization;
|
489 |
|
490 |
|
491 | this.metastring = this.remote + "-" + this.data.id;
|
492 | delete this.data.id;
|
493 |
|
494 | delete this.data.links;
|
495 | }
|
496 | }
|
497 |
|
498 | export function sleep(time = 1000){
|
499 | return new Promise(resolve => setTimeout(resolve, time));
|
500 | }
|