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.
Files changed (45) hide show
  1. package/CHANGELOG.md +14 -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/issue/ApiV3CheerioRestIssueRepository.js +26 -6
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  9. package/bin/adapter/repositories/issue/RestIssueRepository.js +6 -0
  10. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
  11. package/bin/adapter/repositories/projectFieldDefinition.js +88 -0
  12. package/bin/adapter/repositories/projectFieldDefinition.js.map +1 -0
  13. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +10 -16
  14. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
  15. package/package.json +1 -1
  16. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
  17. package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +16 -4
  18. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +8 -27
  19. package/src/adapter/repositories/GraphqlProjectRepository.ts +139 -151
  20. package/src/adapter/repositories/GraphqlProjectRepositoryProjectLocation.test.ts +296 -0
  21. package/src/adapter/repositories/RestProjectRepository.test.ts +277 -0
  22. package/src/adapter/repositories/RestProjectRepository.ts +129 -0
  23. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +58 -0
  24. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +36 -6
  25. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +19 -0
  26. package/src/adapter/repositories/issue/RestIssueRepository.ts +13 -0
  27. package/src/adapter/repositories/projectFieldDefinition.test.ts +189 -0
  28. package/src/adapter/repositories/projectFieldDefinition.ts +131 -0
  29. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +211 -200
  30. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +13 -18
  31. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +5 -0
  32. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +6 -1
  33. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  34. package/types/adapter/repositories/RestProjectRepository.d.ts +18 -0
  35. package/types/adapter/repositories/RestProjectRepository.d.ts.map +1 -0
  36. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +6 -2
  37. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  38. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +1 -0
  39. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
  40. package/types/adapter/repositories/projectFieldDefinition.d.ts +17 -0
  41. package/types/adapter/repositories/projectFieldDefinition.d.ts.map +1 -0
  42. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +2 -2
  43. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
  44. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  45. 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
- 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,
@@ -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
+ });