github-issue-tower-defence-management 1.153.6 → 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 +14 -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/issue/ApiV3CheerioRestIssueRepository.js +26 -6
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/RestIssueRepository.js +6 -0
- package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/projectFieldDefinition.js +88 -0
- package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -0
- package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +10 -16
- package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
- 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/issue/ApiV3CheerioRestIssueRepository.test.ts +58 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +36 -6
- package/src/adapter/repositories/issue/RestIssueRepository.test.ts +19 -0
- package/src/adapter/repositories/issue/RestIssueRepository.ts +13 -0
- package/src/adapter/repositories/projectFieldDefinition.test.ts +189 -0
- package/src/adapter/repositories/projectFieldDefinition.ts +131 -0
- package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +211 -200
- package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +13 -18
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -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/issue/ApiV3CheerioRestIssueRepository.d.ts +6 -2
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -0
- package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/projectFieldDefinition.d.ts +17 -0
- package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -0
- package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +2 -2
- package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
|
@@ -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,
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
const mockGet = jest.fn();
|
|
2
|
+
const mockPostGithubGraphqlJson = jest.fn();
|
|
3
|
+
|
|
4
|
+
jest.mock('ky', () => ({
|
|
5
|
+
default: {
|
|
6
|
+
get: mockGet,
|
|
7
|
+
post: jest.fn(),
|
|
8
|
+
put: jest.fn(),
|
|
9
|
+
patch: jest.fn(),
|
|
10
|
+
delete: jest.fn(),
|
|
11
|
+
extend: jest.fn(),
|
|
12
|
+
create: jest.fn(),
|
|
13
|
+
stop: jest.fn(),
|
|
14
|
+
},
|
|
15
|
+
__esModule: true,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
jest.mock('./githubGraphqlClient', () => ({
|
|
19
|
+
postGithubGraphqlJson: mockPostGithubGraphqlJson,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { GraphqlProjectRepository } from './GraphqlProjectRepository';
|
|
23
|
+
import { LocalStorageRepository } from './LocalStorageRepository';
|
|
24
|
+
|
|
25
|
+
const mockJsonResponse = <T>(data: T) => ({
|
|
26
|
+
json: jest.fn().mockResolvedValue(data),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const projectId = 'PVT_kwHOAGJHa84AFWnr';
|
|
30
|
+
const projectUrl = 'https://github.com/users/HiromiShikata/projects/48';
|
|
31
|
+
|
|
32
|
+
const restProjectResponse = {
|
|
33
|
+
id: 1403371,
|
|
34
|
+
node_id: projectId,
|
|
35
|
+
title: 'UMINO',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const restFieldsResponse = [
|
|
39
|
+
{
|
|
40
|
+
id: 12940049,
|
|
41
|
+
node_id: 'PVTSSF_status',
|
|
42
|
+
name: 'Status',
|
|
43
|
+
options: [
|
|
44
|
+
{
|
|
45
|
+
id: 'f75ad846',
|
|
46
|
+
name: { html: 'Unread', raw: 'Unread' },
|
|
47
|
+
description: { html: '', raw: '' },
|
|
48
|
+
color: 'ORANGE',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const graphqlProjectResponse = {
|
|
55
|
+
data: {
|
|
56
|
+
node: {
|
|
57
|
+
id: projectId,
|
|
58
|
+
databaseId: 1403371,
|
|
59
|
+
title: 'UMINO',
|
|
60
|
+
shortDescription: '',
|
|
61
|
+
public: true,
|
|
62
|
+
closed: false,
|
|
63
|
+
createdAt: '2022-08-27T01:26:40Z',
|
|
64
|
+
updatedAt: '2026-07-24T08:49:59Z',
|
|
65
|
+
number: 48,
|
|
66
|
+
url: projectUrl,
|
|
67
|
+
fields: {
|
|
68
|
+
nodes: [
|
|
69
|
+
{
|
|
70
|
+
id: 'PVTSSF_status',
|
|
71
|
+
databaseId: 12940049,
|
|
72
|
+
name: 'Status',
|
|
73
|
+
dataType: 'SINGLE_SELECT',
|
|
74
|
+
options: [
|
|
75
|
+
{
|
|
76
|
+
id: 'f75ad846',
|
|
77
|
+
name: 'Unread',
|
|
78
|
+
description: '',
|
|
79
|
+
color: 'ORANGE',
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const buildProjectCache = () => {
|
|
90
|
+
const stored = new Map<string, unknown>();
|
|
91
|
+
return {
|
|
92
|
+
stored,
|
|
93
|
+
getLatest: jest.fn(async (key: string) => {
|
|
94
|
+
const value = stored.get(key);
|
|
95
|
+
if (typeof value !== 'object' || value === null) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return { value, timestamp: new Date('2026-08-15T00:00:00Z') };
|
|
99
|
+
}),
|
|
100
|
+
set: jest.fn(async (key: string, value: unknown) => {
|
|
101
|
+
stored.set(key, value);
|
|
102
|
+
}),
|
|
103
|
+
getSingle: jest.fn(async () => null),
|
|
104
|
+
setSingle: jest.fn(async () => undefined),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
describe('GraphqlProjectRepository project location', () => {
|
|
109
|
+
const localStorageRepository = new LocalStorageRepository();
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
mockGet.mockReset();
|
|
113
|
+
mockPostGithubGraphqlJson.mockReset();
|
|
114
|
+
mockGet.mockImplementation((url: string) =>
|
|
115
|
+
url.endsWith('/fields')
|
|
116
|
+
? mockJsonResponse(restFieldsResponse)
|
|
117
|
+
: mockJsonResponse(restProjectResponse),
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should read the project over REST and issue no GraphQL query when the location is already recorded', async () => {
|
|
122
|
+
const projectCache = buildProjectCache();
|
|
123
|
+
projectCache.stored.set(`projectLocation-${projectId}`, {
|
|
124
|
+
owner: 'HiromiShikata',
|
|
125
|
+
ownerType: 'users',
|
|
126
|
+
projectNumber: 48,
|
|
127
|
+
});
|
|
128
|
+
const repository = new GraphqlProjectRepository(
|
|
129
|
+
localStorageRepository,
|
|
130
|
+
'dummy-token',
|
|
131
|
+
projectCache,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const project = await repository.getProject(projectId);
|
|
135
|
+
|
|
136
|
+
expect(mockPostGithubGraphqlJson).not.toHaveBeenCalled();
|
|
137
|
+
expect(mockGet).toHaveBeenCalledWith(
|
|
138
|
+
'https://api.github.com/users/HiromiShikata/projectsV2/48',
|
|
139
|
+
expect.anything(),
|
|
140
|
+
);
|
|
141
|
+
expect(project?.id).toEqual(projectId);
|
|
142
|
+
expect(project?.status.statuses).toEqual([
|
|
143
|
+
{ id: 'f75ad846', name: 'Unread', color: 'ORANGE', description: '' },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should record the location from the project url on the cold start read so the next read goes over REST', async () => {
|
|
148
|
+
const projectCache = buildProjectCache();
|
|
149
|
+
mockPostGithubGraphqlJson.mockResolvedValue(graphqlProjectResponse);
|
|
150
|
+
const repository = new GraphqlProjectRepository(
|
|
151
|
+
localStorageRepository,
|
|
152
|
+
'dummy-token',
|
|
153
|
+
projectCache,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await repository.getProject(projectId);
|
|
157
|
+
|
|
158
|
+
expect(mockPostGithubGraphqlJson).toHaveBeenCalledTimes(1);
|
|
159
|
+
expect(projectCache.stored.get(`projectLocation-${projectId}`)).toEqual({
|
|
160
|
+
owner: 'HiromiShikata',
|
|
161
|
+
ownerType: 'users',
|
|
162
|
+
projectNumber: 48,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const second = await repository.getProject(projectId);
|
|
166
|
+
|
|
167
|
+
expect(mockPostGithubGraphqlJson).toHaveBeenCalledTimes(1);
|
|
168
|
+
expect(second?.id).toEqual(projectId);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should list the field names over REST from the project url without any GraphQL query', async () => {
|
|
172
|
+
const repository = new GraphqlProjectRepository(
|
|
173
|
+
localStorageRepository,
|
|
174
|
+
'dummy-token',
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const names = await repository.listFieldNames({
|
|
178
|
+
id: projectId,
|
|
179
|
+
url: projectUrl,
|
|
180
|
+
databaseId: 1403371,
|
|
181
|
+
name: 'UMINO',
|
|
182
|
+
status: { name: 'Status', fieldId: 'PVTSSF_status', statuses: [] },
|
|
183
|
+
nextActionDate: null,
|
|
184
|
+
nextActionHour: null,
|
|
185
|
+
story: null,
|
|
186
|
+
remainingEstimationMinutes: null,
|
|
187
|
+
dependedIssueUrlSeparatedByComma: null,
|
|
188
|
+
completionDate50PercentConfidence: null,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(mockPostGithubGraphqlJson).not.toHaveBeenCalled();
|
|
192
|
+
expect(mockGet).toHaveBeenCalledWith(
|
|
193
|
+
'https://api.github.com/users/HiromiShikata/projectsV2/48/fields',
|
|
194
|
+
{
|
|
195
|
+
searchParams: { per_page: 100 },
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: 'token dummy-token',
|
|
198
|
+
Accept: 'application/vnd.github+json',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
expect(names).toEqual(['Status']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should warn and fall back to the GraphQL project query when the location cache read throws', async () => {
|
|
206
|
+
const projectCache = buildProjectCache();
|
|
207
|
+
projectCache.getLatest.mockRejectedValue(new Error('corrupted file'));
|
|
208
|
+
mockPostGithubGraphqlJson.mockResolvedValue(graphqlProjectResponse);
|
|
209
|
+
const warn = jest
|
|
210
|
+
.spyOn(console, 'warn')
|
|
211
|
+
.mockImplementation(() => undefined);
|
|
212
|
+
const repository = new GraphqlProjectRepository(
|
|
213
|
+
localStorageRepository,
|
|
214
|
+
'dummy-token',
|
|
215
|
+
projectCache,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const project = await repository.getProject(projectId);
|
|
219
|
+
|
|
220
|
+
expect(project?.id).toEqual(projectId);
|
|
221
|
+
expect(mockPostGithubGraphqlJson).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(warn).toHaveBeenCalledWith(
|
|
223
|
+
expect.stringContaining(
|
|
224
|
+
'reading the project location disk cache failed, falling back to the GraphQL project query',
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
expect(warn).toHaveBeenCalledWith(
|
|
228
|
+
expect.stringContaining('corrupted file'),
|
|
229
|
+
);
|
|
230
|
+
warn.mockRestore();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should warn and still return the project when the location cache write throws', async () => {
|
|
234
|
+
const projectCache = buildProjectCache();
|
|
235
|
+
projectCache.set.mockRejectedValue(new Error('disk full'));
|
|
236
|
+
mockPostGithubGraphqlJson.mockResolvedValue(graphqlProjectResponse);
|
|
237
|
+
const warn = jest
|
|
238
|
+
.spyOn(console, 'warn')
|
|
239
|
+
.mockImplementation(() => undefined);
|
|
240
|
+
const repository = new GraphqlProjectRepository(
|
|
241
|
+
localStorageRepository,
|
|
242
|
+
'dummy-token',
|
|
243
|
+
projectCache,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const project = await repository.getProject(projectId);
|
|
247
|
+
|
|
248
|
+
expect(project?.id).toEqual(projectId);
|
|
249
|
+
expect(warn).toHaveBeenCalledWith(
|
|
250
|
+
expect.stringContaining(
|
|
251
|
+
'writing the project location disk cache failed, every later process will fall back to the GraphQL project query',
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('disk full'));
|
|
255
|
+
warn.mockRestore();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should discard a recorded location that no longer resolves and re-read the project over GraphQL', async () => {
|
|
259
|
+
const projectCache = buildProjectCache();
|
|
260
|
+
projectCache.stored.set(`projectLocation-${projectId}`, {
|
|
261
|
+
owner: 'RenamedAway',
|
|
262
|
+
ownerType: 'users',
|
|
263
|
+
projectNumber: 48,
|
|
264
|
+
});
|
|
265
|
+
mockGet.mockImplementation(() => {
|
|
266
|
+
throw Object.assign(new Error('Not Found'), {
|
|
267
|
+
response: { status: 404 },
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
mockPostGithubGraphqlJson.mockResolvedValue(graphqlProjectResponse);
|
|
271
|
+
const warn = jest
|
|
272
|
+
.spyOn(console, 'warn')
|
|
273
|
+
.mockImplementation(() => undefined);
|
|
274
|
+
const repository = new GraphqlProjectRepository(
|
|
275
|
+
localStorageRepository,
|
|
276
|
+
'dummy-token',
|
|
277
|
+
projectCache,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const project = await repository.getProject(projectId);
|
|
281
|
+
|
|
282
|
+
expect(project?.id).toEqual(projectId);
|
|
283
|
+
expect(mockPostGithubGraphqlJson).toHaveBeenCalledTimes(1);
|
|
284
|
+
expect(warn).toHaveBeenCalledWith(
|
|
285
|
+
expect.stringContaining(
|
|
286
|
+
'the recorded project location no longer resolves over REST, re-reading the project over GraphQL',
|
|
287
|
+
),
|
|
288
|
+
);
|
|
289
|
+
expect(projectCache.stored.get(`projectLocation-${projectId}`)).toEqual({
|
|
290
|
+
owner: 'HiromiShikata',
|
|
291
|
+
ownerType: 'users',
|
|
292
|
+
projectNumber: 48,
|
|
293
|
+
});
|
|
294
|
+
warn.mockRestore();
|
|
295
|
+
});
|
|
296
|
+
});
|