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.
Files changed (23) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +6 -3
  3. package/bin/adapter/repositories/GraphqlProjectRepository.js +102 -114
  4. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  5. package/bin/adapter/repositories/RestProjectRepository.js +95 -0
  6. package/bin/adapter/repositories/RestProjectRepository.js.map +1 -0
  7. package/bin/adapter/repositories/projectFieldDefinition.js +88 -0
  8. package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -0
  9. package/package.json +1 -1
  10. package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +16 -4
  11. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +8 -27
  12. package/src/adapter/repositories/GraphqlProjectRepository.ts +139 -151
  13. package/src/adapter/repositories/GraphqlProjectRepositoryProjectLocation.test.ts +296 -0
  14. package/src/adapter/repositories/RestProjectRepository.test.ts +277 -0
  15. package/src/adapter/repositories/RestProjectRepository.ts +129 -0
  16. package/src/adapter/repositories/projectFieldDefinition.test.ts +189 -0
  17. package/src/adapter/repositories/projectFieldDefinition.ts +131 -0
  18. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +6 -1
  19. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  20. package/types/adapter/repositories/RestProjectRepository.d.ts +18 -0
  21. package/types/adapter/repositories/RestProjectRepository.d.ts.map +1 -0
  22. package/types/adapter/repositories/projectFieldDefinition.d.ts +17 -0
  23. 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
- DEPENDED_ISSUE_URL_FIELD_NAME,
10
- NEXT_ACTION_DATE_FIELD_NAME,
11
- NEXT_ACTION_HOUR_FIELD_NAME,
12
- RequiredProjectFieldDefinition,
13
- STORY_FIELD_NAME,
14
- } from '../../domain/entities/RequiredProjectField';
15
- import { normalizeFieldName } from './utils';
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
- export const convertToFieldOptionColor = (
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 nextActionDate = project.fields.nodes.find(
360
- (field) =>
361
- normalizeFieldName(field.name) ===
362
- normalizeFieldName(NEXT_ACTION_DATE_FIELD_NAME),
363
- );
364
- const nextActionHour = project.fields.nodes.find(
365
- (field) =>
366
- normalizeFieldName(field.name) ===
367
- normalizeFieldName(NEXT_ACTION_HOUR_FIELD_NAME),
368
- );
369
- const status = project.fields.nodes.find(
370
- (field) => normalizeFieldName(field.name) === 'status',
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
- if (!status) {
373
- throw new Error('status field is not found');
462
+ const location = projectLocationFromUrl(project.url);
463
+ if (location) {
464
+ await this.rememberProjectLocation(project.id, location);
374
465
  }
375
- const story = project.fields.nodes.find(
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
- status: {
401
- name: status.name,
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 query = `query ListProjectV2FieldNames($projectId: ID!) {
470
- node(id: $projectId) {
471
- ... on ProjectV2 {
472
- fields(first: 100) {
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
- `GitHub GraphQL API returned no data for listFieldNames: ${errorMessages}`,
491
+ `listFieldNames: project location is unknown for ${project.id}`,
502
492
  );
503
493
  }
504
- return response.data.node.fields.nodes
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,