github-issue-tower-defence-management 1.154.0 → 1.154.1
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 +7 -0
- package/README.md +6 -3
- package/bin/adapter/repositories/GraphqlProjectRepository.js +102 -114
- package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
- package/bin/adapter/repositories/RestProjectRepository.js +95 -0
- package/bin/adapter/repositories/RestProjectRepository.js.map +1 -0
- package/bin/adapter/repositories/projectFieldDefinition.js +88 -0
- package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +16 -4
- package/src/adapter/repositories/GraphqlProjectRepository.test.ts +8 -27
- package/src/adapter/repositories/GraphqlProjectRepository.ts +139 -151
- package/src/adapter/repositories/GraphqlProjectRepositoryProjectLocation.test.ts +296 -0
- package/src/adapter/repositories/RestProjectRepository.test.ts +277 -0
- package/src/adapter/repositories/RestProjectRepository.ts +129 -0
- package/src/adapter/repositories/projectFieldDefinition.test.ts +189 -0
- package/src/adapter/repositories/projectFieldDefinition.ts +131 -0
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts +6 -1
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
- package/types/adapter/repositories/RestProjectRepository.d.ts +18 -0
- package/types/adapter/repositories/RestProjectRepository.d.ts.map +1 -0
- package/types/adapter/repositories/projectFieldDefinition.d.ts +17 -0
- package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -0
|
@@ -1,33 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
GraphqlProjectRepository,
|
|
3
|
-
convertToFieldOptionColor,
|
|
4
|
-
} from './GraphqlProjectRepository';
|
|
1
|
+
import { GraphqlProjectRepository } from './GraphqlProjectRepository';
|
|
5
2
|
import { LocalStorageRepository } from './LocalStorageRepository';
|
|
6
3
|
import { FieldOption, Project } from '../../domain/entities/Project';
|
|
7
4
|
|
|
8
|
-
describe('convertToFieldOptionColor', () => {
|
|
9
|
-
it('should preserve PINK so the Todo by human status button renders pink', () => {
|
|
10
|
-
expect(convertToFieldOptionColor('PINK')).toEqual('PINK');
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('should preserve ORANGE so the Unread status button renders orange', () => {
|
|
14
|
-
expect(convertToFieldOptionColor('ORANGE')).toEqual('ORANGE');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('should preserve the remaining GitHub project option colors', () => {
|
|
18
|
-
expect(convertToFieldOptionColor('RED')).toEqual('RED');
|
|
19
|
-
expect(convertToFieldOptionColor('YELLOW')).toEqual('YELLOW');
|
|
20
|
-
expect(convertToFieldOptionColor('GREEN')).toEqual('GREEN');
|
|
21
|
-
expect(convertToFieldOptionColor('BLUE')).toEqual('BLUE');
|
|
22
|
-
expect(convertToFieldOptionColor('PURPLE')).toEqual('PURPLE');
|
|
23
|
-
expect(convertToFieldOptionColor('GRAY')).toEqual('GRAY');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should fall back to GRAY for an unknown color value', () => {
|
|
27
|
-
expect(convertToFieldOptionColor('UNKNOWN')).toEqual('GRAY');
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
5
|
const token = process.env.GH_TOKEN;
|
|
32
6
|
const describeWhenCredentials = token ? describe : describe.skip;
|
|
33
7
|
|
|
@@ -222,5 +196,12 @@ describeWhenCredentials('GraphqlProjectRepository', () => {
|
|
|
222
196
|
},
|
|
223
197
|
});
|
|
224
198
|
});
|
|
199
|
+
|
|
200
|
+
it('should return the same project from the REST read as from the GraphQL cold start read', async () => {
|
|
201
|
+
const fromGraphql = await repository.getProject(projectId);
|
|
202
|
+
const fromRest = await repository.getProject(projectId);
|
|
203
|
+
|
|
204
|
+
expect(fromRest).toEqual(fromGraphql);
|
|
205
|
+
}, 60000);
|
|
225
206
|
});
|
|
226
207
|
});
|
|
@@ -5,36 +5,23 @@ import { ProjectIssuesCacheRepository } from './ProjectIssuesCacheRepository';
|
|
|
5
5
|
import { LocalStorageRepository } from './LocalStorageRepository';
|
|
6
6
|
import { ProjectRepository } from '../../domain/usecases/adapter-interfaces/ProjectRepository';
|
|
7
7
|
import { FieldOption, Project } from '../../domain/entities/Project';
|
|
8
|
+
import { RequiredProjectFieldDefinition } from '../../domain/entities/RequiredProjectField';
|
|
8
9
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
ProjectFieldDefinition,
|
|
11
|
+
convertToFieldOptionColor,
|
|
12
|
+
projectFromDefinition,
|
|
13
|
+
} from './projectFieldDefinition';
|
|
14
|
+
import {
|
|
15
|
+
ProjectLocation,
|
|
16
|
+
RestProjectRepository,
|
|
17
|
+
projectLocationFromUrl,
|
|
18
|
+
} from './RestProjectRepository';
|
|
16
19
|
|
|
17
20
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
18
21
|
|
|
19
22
|
const PROJECT_ID_DISK_CACHE_KEY_PREFIX = 'projectId';
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
color: string,
|
|
23
|
-
): FieldOption['color'] => {
|
|
24
|
-
switch (color) {
|
|
25
|
-
case 'RED':
|
|
26
|
-
case 'YELLOW':
|
|
27
|
-
case 'GREEN':
|
|
28
|
-
case 'BLUE':
|
|
29
|
-
case 'PURPLE':
|
|
30
|
-
case 'ORANGE':
|
|
31
|
-
case 'PINK':
|
|
32
|
-
case 'GRAY':
|
|
33
|
-
return color;
|
|
34
|
-
default:
|
|
35
|
-
return 'GRAY';
|
|
36
|
-
}
|
|
37
|
-
};
|
|
24
|
+
const PROJECT_LOCATION_DISK_CACHE_KEY_PREFIX = 'projectLocation';
|
|
38
25
|
|
|
39
26
|
export class GraphqlProjectRepository
|
|
40
27
|
extends BaseGitHubRepository
|
|
@@ -52,11 +39,13 @@ export class GraphqlProjectRepository
|
|
|
52
39
|
{
|
|
53
40
|
private readonly projectIdCache = new Map<string, string>();
|
|
54
41
|
private readonly fetchProjectIdFailedAt = new Map<string, number>();
|
|
42
|
+
private readonly projectLocationCache = new Map<string, ProjectLocation>();
|
|
55
43
|
private readonly projectCache?: Pick<
|
|
56
44
|
LocalStorageCacheRepository,
|
|
57
45
|
'getLatest' | 'set'
|
|
58
46
|
>;
|
|
59
47
|
private readonly projectIssuesCacheRepository: ProjectIssuesCacheRepository | null;
|
|
48
|
+
private readonly restProjectRepository: RestProjectRepository;
|
|
60
49
|
|
|
61
50
|
constructor(
|
|
62
51
|
localStorageRepository: LocalStorageRepository,
|
|
@@ -72,8 +61,87 @@ export class GraphqlProjectRepository
|
|
|
72
61
|
projectCache === undefined
|
|
73
62
|
? null
|
|
74
63
|
: new ProjectIssuesCacheRepository(projectCache);
|
|
64
|
+
this.restProjectRepository = new RestProjectRepository(
|
|
65
|
+
localStorageRepository,
|
|
66
|
+
ghToken,
|
|
67
|
+
);
|
|
75
68
|
}
|
|
76
69
|
|
|
70
|
+
private readProjectLocationFromDiskCache = async (
|
|
71
|
+
projectId: Project['id'],
|
|
72
|
+
): Promise<ProjectLocation | null> => {
|
|
73
|
+
if (!this.projectCache) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
let cache: { value: object; timestamp: Date } | null;
|
|
77
|
+
try {
|
|
78
|
+
cache = await this.projectCache.getLatest(
|
|
79
|
+
`${PROJECT_LOCATION_DISK_CACHE_KEY_PREFIX}-${projectId}`,
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.warn(
|
|
83
|
+
`GraphqlProjectRepository: reading the project location disk cache failed, falling back to the GraphQL project query. projectId: ${projectId}, error: ${String(error)}`,
|
|
84
|
+
);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!cache) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
const value: unknown = cache.value;
|
|
91
|
+
if (
|
|
92
|
+
typeof value !== 'object' ||
|
|
93
|
+
value === null ||
|
|
94
|
+
!('owner' in value) ||
|
|
95
|
+
!('ownerType' in value) ||
|
|
96
|
+
!('projectNumber' in value) ||
|
|
97
|
+
typeof value.owner !== 'string' ||
|
|
98
|
+
typeof value.projectNumber !== 'number' ||
|
|
99
|
+
(value.ownerType !== 'users' && value.ownerType !== 'orgs')
|
|
100
|
+
) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
owner: value.owner,
|
|
105
|
+
ownerType: value.ownerType,
|
|
106
|
+
projectNumber: value.projectNumber,
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
private rememberProjectLocation = async (
|
|
111
|
+
projectId: Project['id'],
|
|
112
|
+
location: ProjectLocation,
|
|
113
|
+
): Promise<void> => {
|
|
114
|
+
this.projectLocationCache.set(projectId, location);
|
|
115
|
+
if (!this.projectCache) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await this.projectCache.set(
|
|
120
|
+
`${PROJECT_LOCATION_DISK_CACHE_KEY_PREFIX}-${projectId}`,
|
|
121
|
+
location,
|
|
122
|
+
);
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`GraphqlProjectRepository: writing the project location disk cache failed, every later process will fall back to the GraphQL project query. projectId: ${projectId}, error: ${String(error)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
private findProjectLocation = async (
|
|
131
|
+
projectId: Project['id'],
|
|
132
|
+
): Promise<ProjectLocation | null> => {
|
|
133
|
+
const cached = this.projectLocationCache.get(projectId);
|
|
134
|
+
if (cached) {
|
|
135
|
+
return cached;
|
|
136
|
+
}
|
|
137
|
+
const diskCached = await this.readProjectLocationFromDiskCache(projectId);
|
|
138
|
+
if (diskCached) {
|
|
139
|
+
this.projectLocationCache.set(projectId, diskCached);
|
|
140
|
+
return diskCached;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
};
|
|
144
|
+
|
|
77
145
|
private readProjectIdFromDiskCache = async (
|
|
78
146
|
cacheKey: string,
|
|
79
147
|
): Promise<string | null> => {
|
|
@@ -237,6 +305,11 @@ export class GraphqlProjectRepository
|
|
|
237
305
|
}
|
|
238
306
|
this.projectIdCache.set(cacheKey, projectId);
|
|
239
307
|
await this.writeProjectIdToDiskCache(cacheKey, projectId);
|
|
308
|
+
await this.rememberProjectLocation(projectId, {
|
|
309
|
+
owner: login,
|
|
310
|
+
ownerType: response.data.organization?.projectV2?.id ? 'orgs' : 'users',
|
|
311
|
+
projectNumber,
|
|
312
|
+
});
|
|
240
313
|
return projectId;
|
|
241
314
|
};
|
|
242
315
|
findProjectIdByUrl = async (
|
|
@@ -246,6 +319,23 @@ export class GraphqlProjectRepository
|
|
|
246
319
|
return await this.fetchProjectId(owner, projectNumber);
|
|
247
320
|
};
|
|
248
321
|
getProject = async (projectId: Project['id']): Promise<Project | null> => {
|
|
322
|
+
const location = await this.findProjectLocation(projectId);
|
|
323
|
+
if (location) {
|
|
324
|
+
const project = await this.restProjectRepository.getProject(location);
|
|
325
|
+
if (project) {
|
|
326
|
+
return project;
|
|
327
|
+
}
|
|
328
|
+
console.warn(
|
|
329
|
+
`GraphqlProjectRepository: the recorded project location no longer resolves over REST, re-reading the project over GraphQL. projectId: ${projectId}, owner: ${location.owner}, projectNumber: ${location.projectNumber}`,
|
|
330
|
+
);
|
|
331
|
+
this.projectLocationCache.delete(projectId);
|
|
332
|
+
}
|
|
333
|
+
return await this.fetchProjectByGraphql(projectId);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
private fetchProjectByGraphql = async (
|
|
337
|
+
projectId: Project['id'],
|
|
338
|
+
): Promise<Project | null> => {
|
|
249
339
|
const query = `query GetProjectV2($projectId: ID!) {
|
|
250
340
|
node(id: $projectId) {
|
|
251
341
|
... on ProjectV2 {
|
|
@@ -328,7 +418,7 @@ export class GraphqlProjectRepository
|
|
|
328
418
|
title: string;
|
|
329
419
|
}[];
|
|
330
420
|
};
|
|
331
|
-
options
|
|
421
|
+
options?: {
|
|
332
422
|
id: string;
|
|
333
423
|
name: string;
|
|
334
424
|
description: string;
|
|
@@ -356,103 +446,30 @@ export class GraphqlProjectRepository
|
|
|
356
446
|
if (!project) {
|
|
357
447
|
return null;
|
|
358
448
|
}
|
|
359
|
-
const
|
|
360
|
-
(field) =>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
449
|
+
const fields: ProjectFieldDefinition[] = project.fields.nodes.map(
|
|
450
|
+
(field) => ({
|
|
451
|
+
fieldId: field.id,
|
|
452
|
+
databaseId: field.databaseId,
|
|
453
|
+
name: field.name,
|
|
454
|
+
options: (field.options ?? []).map((option) => ({
|
|
455
|
+
id: option.id,
|
|
456
|
+
name: option.name,
|
|
457
|
+
color: convertToFieldOptionColor(option.color),
|
|
458
|
+
description: option.description,
|
|
459
|
+
})),
|
|
460
|
+
}),
|
|
371
461
|
);
|
|
372
|
-
|
|
373
|
-
|
|
462
|
+
const location = projectLocationFromUrl(project.url);
|
|
463
|
+
if (location) {
|
|
464
|
+
await this.rememberProjectLocation(project.id, location);
|
|
374
465
|
}
|
|
375
|
-
|
|
376
|
-
(field) =>
|
|
377
|
-
normalizeFieldName(field.name) === normalizeFieldName(STORY_FIELD_NAME),
|
|
378
|
-
);
|
|
379
|
-
const workflowManagementStory = story?.options.find((option) =>
|
|
380
|
-
normalizeFieldName(option.name).includes('workflowmanagement'),
|
|
381
|
-
);
|
|
382
|
-
const remainignEstimationMinutes = project.fields.nodes.find(
|
|
383
|
-
(field) =>
|
|
384
|
-
normalizeFieldName(field.name) === 'remainingestimationminutes',
|
|
385
|
-
);
|
|
386
|
-
const dependedIssueUrlSeparatedByComma = project.fields.nodes.find(
|
|
387
|
-
(field) =>
|
|
388
|
-
normalizeFieldName(field.name).startsWith(
|
|
389
|
-
normalizeFieldName(DEPENDED_ISSUE_URL_FIELD_NAME),
|
|
390
|
-
),
|
|
391
|
-
);
|
|
392
|
-
const completionDate50PercentConfidence = project.fields.nodes.find(
|
|
393
|
-
(field) => normalizeFieldName(field.name).startsWith('completiondate'),
|
|
394
|
-
);
|
|
395
|
-
return {
|
|
466
|
+
return projectFromDefinition({
|
|
396
467
|
id: project.id,
|
|
397
468
|
url: project.url,
|
|
398
469
|
databaseId: project.databaseId,
|
|
399
470
|
name: project.title,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
fieldId: status.id,
|
|
403
|
-
statuses: status.options.map((option) => ({
|
|
404
|
-
id: option.id,
|
|
405
|
-
name: option.name,
|
|
406
|
-
color: convertToFieldOptionColor(option.color),
|
|
407
|
-
description: option.description,
|
|
408
|
-
})),
|
|
409
|
-
},
|
|
410
|
-
nextActionDate: nextActionDate
|
|
411
|
-
? {
|
|
412
|
-
name: nextActionDate.name,
|
|
413
|
-
fieldId: nextActionDate.id,
|
|
414
|
-
}
|
|
415
|
-
: null,
|
|
416
|
-
nextActionHour: nextActionHour
|
|
417
|
-
? {
|
|
418
|
-
name: nextActionHour.name,
|
|
419
|
-
fieldId: nextActionHour.id,
|
|
420
|
-
}
|
|
421
|
-
: null,
|
|
422
|
-
story:
|
|
423
|
-
story && workflowManagementStory
|
|
424
|
-
? {
|
|
425
|
-
name: story.name,
|
|
426
|
-
fieldId: story.id,
|
|
427
|
-
databaseId: story.databaseId,
|
|
428
|
-
stories: story.options.map((option) => ({
|
|
429
|
-
id: option.id,
|
|
430
|
-
name: option.name,
|
|
431
|
-
color: convertToFieldOptionColor(option.color),
|
|
432
|
-
description: option.description,
|
|
433
|
-
})),
|
|
434
|
-
workflowManagementStory,
|
|
435
|
-
}
|
|
436
|
-
: null,
|
|
437
|
-
remainingEstimationMinutes: remainignEstimationMinutes
|
|
438
|
-
? {
|
|
439
|
-
name: remainignEstimationMinutes.name,
|
|
440
|
-
fieldId: remainignEstimationMinutes.id,
|
|
441
|
-
}
|
|
442
|
-
: null,
|
|
443
|
-
dependedIssueUrlSeparatedByComma: dependedIssueUrlSeparatedByComma
|
|
444
|
-
? {
|
|
445
|
-
name: dependedIssueUrlSeparatedByComma.name,
|
|
446
|
-
fieldId: dependedIssueUrlSeparatedByComma.id,
|
|
447
|
-
}
|
|
448
|
-
: null,
|
|
449
|
-
completionDate50PercentConfidence: completionDate50PercentConfidence
|
|
450
|
-
? {
|
|
451
|
-
name: completionDate50PercentConfidence.name,
|
|
452
|
-
fieldId: completionDate50PercentConfidence.id,
|
|
453
|
-
}
|
|
454
|
-
: null,
|
|
455
|
-
};
|
|
471
|
+
fields,
|
|
472
|
+
});
|
|
456
473
|
};
|
|
457
474
|
getByUrl = async (url: string): Promise<Project> => {
|
|
458
475
|
const projectId = await this.findProjectIdByUrl(url);
|
|
@@ -466,44 +483,15 @@ export class GraphqlProjectRepository
|
|
|
466
483
|
return project;
|
|
467
484
|
};
|
|
468
485
|
listFieldNames = async (project: Project): Promise<string[]> => {
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
nodes {
|
|
474
|
-
... on ProjectV2FieldCommon {
|
|
475
|
-
name
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}`;
|
|
482
|
-
const response = await postGithubGraphqlJson<{
|
|
483
|
-
data?: {
|
|
484
|
-
node: {
|
|
485
|
-
fields: {
|
|
486
|
-
nodes: { name?: string }[];
|
|
487
|
-
};
|
|
488
|
-
} | null;
|
|
489
|
-
};
|
|
490
|
-
errors?: { message: string }[];
|
|
491
|
-
}>({
|
|
492
|
-
ghToken: this.ghToken,
|
|
493
|
-
query,
|
|
494
|
-
variables: { projectId: project.id },
|
|
495
|
-
});
|
|
496
|
-
if (!response.data || !response.data.node) {
|
|
497
|
-
const errorMessages = response.errors
|
|
498
|
-
? response.errors.map((e) => e.message).join('; ')
|
|
499
|
-
: 'no data field in response';
|
|
486
|
+
const location =
|
|
487
|
+
projectLocationFromUrl(project.url) ??
|
|
488
|
+
(await this.findProjectLocation(project.id));
|
|
489
|
+
if (!location) {
|
|
500
490
|
throw new Error(
|
|
501
|
-
`
|
|
491
|
+
`listFieldNames: project location is unknown for ${project.id}`,
|
|
502
492
|
);
|
|
503
493
|
}
|
|
504
|
-
return
|
|
505
|
-
.map((field) => field.name)
|
|
506
|
-
.filter((name): name is string => typeof name === 'string');
|
|
494
|
+
return await this.restProjectRepository.listFieldNames(location);
|
|
507
495
|
};
|
|
508
496
|
createField = async (
|
|
509
497
|
project: Project,
|