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
@@ -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
+ });
@@ -0,0 +1,277 @@
1
+ const mockGet = jest.fn();
2
+
3
+ jest.mock('ky', () => ({
4
+ default: {
5
+ get: mockGet,
6
+ post: jest.fn(),
7
+ put: jest.fn(),
8
+ patch: jest.fn(),
9
+ delete: jest.fn(),
10
+ extend: jest.fn(),
11
+ create: jest.fn(),
12
+ stop: jest.fn(),
13
+ },
14
+ __esModule: true,
15
+ }));
16
+
17
+ import {
18
+ RestProjectRepository,
19
+ projectLocationFromUrl,
20
+ projectUrlFromLocation,
21
+ } from './RestProjectRepository';
22
+ import { LocalStorageRepository } from './LocalStorageRepository';
23
+
24
+ const mockJsonResponse = <T>(data: T) => ({
25
+ json: jest.fn().mockResolvedValue(data),
26
+ });
27
+
28
+ const projectResponse = {
29
+ id: 1403371,
30
+ node_id: 'PVT_kwHOAGJHa84AFWnr',
31
+ title: 'UMINO',
32
+ };
33
+
34
+ const fieldsResponse = [
35
+ {
36
+ id: 12940049,
37
+ node_id: 'PVTSSF_status',
38
+ name: 'Status',
39
+ options: [
40
+ {
41
+ id: 'f75ad846',
42
+ name: { html: 'Unread', raw: 'Unread' },
43
+ description: { html: '', raw: '' },
44
+ color: 'ORANGE',
45
+ },
46
+ {
47
+ id: 'e9931e57',
48
+ name: { html: 'Todo by human', raw: 'Todo by human' },
49
+ description: { html: 'own queue', raw: 'own queue' },
50
+ color: 'PINK',
51
+ },
52
+ ],
53
+ },
54
+ {
55
+ id: 133939017,
56
+ node_id: 'PVTSSF_story',
57
+ name: 'story',
58
+ options: [
59
+ {
60
+ id: '6dc26727',
61
+ name: {
62
+ html: 'regular / workflow management',
63
+ raw: 'regular / workflow management',
64
+ },
65
+ description: { html: '', raw: '' },
66
+ color: 'BLUE',
67
+ },
68
+ ],
69
+ },
70
+ {
71
+ id: 35978365,
72
+ node_id: 'PVTF_nextactiondate',
73
+ name: 'nextactiondate',
74
+ },
75
+ ];
76
+
77
+ describe('RestProjectRepository', () => {
78
+ const localStorageRepository = new LocalStorageRepository();
79
+ const repository = new RestProjectRepository(
80
+ localStorageRepository,
81
+ 'dummy-token',
82
+ );
83
+ const location = {
84
+ owner: 'HiromiShikata',
85
+ ownerType: 'users' as const,
86
+ projectNumber: 48,
87
+ };
88
+
89
+ afterEach(() => {
90
+ mockGet.mockReset();
91
+ });
92
+
93
+ describe('listFieldDefinitions', () => {
94
+ it('should read the fields from the projects REST endpoint and convert the option colors', async () => {
95
+ mockGet.mockReturnValueOnce(mockJsonResponse(fieldsResponse));
96
+
97
+ const fields = await repository.listFieldDefinitions(location);
98
+
99
+ expect(mockGet).toHaveBeenCalledTimes(1);
100
+ expect(mockGet).toHaveBeenCalledWith(
101
+ 'https://api.github.com/users/HiromiShikata/projectsV2/48/fields',
102
+ {
103
+ searchParams: { per_page: 100 },
104
+ headers: {
105
+ Authorization: 'token dummy-token',
106
+ Accept: 'application/vnd.github+json',
107
+ },
108
+ },
109
+ );
110
+ expect(fields).toEqual([
111
+ {
112
+ fieldId: 'PVTSSF_status',
113
+ databaseId: 12940049,
114
+ name: 'Status',
115
+ options: [
116
+ {
117
+ id: 'f75ad846',
118
+ name: 'Unread',
119
+ color: 'ORANGE',
120
+ description: '',
121
+ },
122
+ {
123
+ id: 'e9931e57',
124
+ name: 'Todo by human',
125
+ color: 'PINK',
126
+ description: 'own queue',
127
+ },
128
+ ],
129
+ },
130
+ {
131
+ fieldId: 'PVTSSF_story',
132
+ databaseId: 133939017,
133
+ name: 'story',
134
+ options: [
135
+ {
136
+ id: '6dc26727',
137
+ name: 'regular / workflow management',
138
+ color: 'BLUE',
139
+ description: '',
140
+ },
141
+ ],
142
+ },
143
+ {
144
+ fieldId: 'PVTF_nextactiondate',
145
+ databaseId: 35978365,
146
+ name: 'nextactiondate',
147
+ options: [],
148
+ },
149
+ ]);
150
+ });
151
+
152
+ it('should address the organization route for an organization owned project', async () => {
153
+ mockGet.mockReturnValueOnce(mockJsonResponse([]));
154
+
155
+ await repository.listFieldDefinitions({
156
+ owner: 'X-Mile',
157
+ ownerType: 'orgs',
158
+ projectNumber: 7,
159
+ });
160
+
161
+ expect(mockGet).toHaveBeenCalledWith(
162
+ 'https://api.github.com/orgs/X-Mile/projectsV2/7/fields',
163
+ expect.anything(),
164
+ );
165
+ });
166
+ });
167
+
168
+ describe('listFieldNames', () => {
169
+ it('should return every field name including the ones the project entity does not keep', async () => {
170
+ mockGet.mockReturnValueOnce(mockJsonResponse(fieldsResponse));
171
+
172
+ const names = await repository.listFieldNames(location);
173
+
174
+ expect(names).toEqual(['Status', 'story', 'nextactiondate']);
175
+ });
176
+ });
177
+
178
+ describe('getProject', () => {
179
+ it('should build the project from the REST project and field responses', async () => {
180
+ mockGet.mockImplementation((url: string) =>
181
+ url.endsWith('/fields')
182
+ ? mockJsonResponse(fieldsResponse)
183
+ : mockJsonResponse(projectResponse),
184
+ );
185
+
186
+ const project = await repository.getProject(location);
187
+
188
+ expect(project?.id).toEqual('PVT_kwHOAGJHa84AFWnr');
189
+ expect(project?.databaseId).toEqual(1403371);
190
+ expect(project?.name).toEqual('UMINO');
191
+ expect(project?.url).toEqual(
192
+ 'https://github.com/users/HiromiShikata/projects/48',
193
+ );
194
+ expect(project?.status.fieldId).toEqual('PVTSSF_status');
195
+ expect(project?.status.statuses.map((status) => status.name)).toEqual([
196
+ 'Unread',
197
+ 'Todo by human',
198
+ ]);
199
+ expect(project?.story?.workflowManagementStory).toEqual({
200
+ id: '6dc26727',
201
+ name: 'regular / workflow management',
202
+ color: 'BLUE',
203
+ description: '',
204
+ });
205
+ expect(project?.nextActionDate).toEqual({
206
+ name: 'nextactiondate',
207
+ fieldId: 'PVTF_nextactiondate',
208
+ });
209
+ });
210
+
211
+ it('should return null when the project no longer exists at that owner and number', async () => {
212
+ mockGet.mockImplementation(() => {
213
+ throw Object.assign(new Error('Not Found'), {
214
+ response: { status: 404 },
215
+ });
216
+ });
217
+
218
+ expect(await repository.getProject(location)).toBeNull();
219
+ });
220
+
221
+ it('should rethrow a failure that is not a not found response', async () => {
222
+ mockGet.mockImplementation(() => {
223
+ throw Object.assign(new Error('Bad Gateway'), {
224
+ response: { status: 502 },
225
+ });
226
+ });
227
+
228
+ await expect(repository.getProject(location)).rejects.toThrow(
229
+ 'Bad Gateway',
230
+ );
231
+ });
232
+ });
233
+ });
234
+
235
+ describe('projectLocationFromUrl', () => {
236
+ it('should read the owner and the number from a user owned project url', () => {
237
+ expect(
238
+ projectLocationFromUrl(
239
+ 'https://github.com/users/HiromiShikata/projects/48',
240
+ ),
241
+ ).toEqual({
242
+ owner: 'HiromiShikata',
243
+ ownerType: 'users',
244
+ projectNumber: 48,
245
+ });
246
+ });
247
+
248
+ it('should read the owner and the number from an organization owned project url', () => {
249
+ expect(
250
+ projectLocationFromUrl('https://github.com/orgs/X-Mile/projects/7'),
251
+ ).toEqual({
252
+ owner: 'X-Mile',
253
+ ownerType: 'orgs',
254
+ projectNumber: 7,
255
+ });
256
+ });
257
+
258
+ it('should return null for a url that is not a project url', () => {
259
+ expect(
260
+ projectLocationFromUrl(
261
+ 'https://github.com/HiromiShikata/secretary/issues/1',
262
+ ),
263
+ ).toBeNull();
264
+ });
265
+ });
266
+
267
+ describe('projectUrlFromLocation', () => {
268
+ it('should rebuild the project url the GraphQL API returns', () => {
269
+ expect(
270
+ projectUrlFromLocation({
271
+ owner: 'HiromiShikata',
272
+ ownerType: 'users',
273
+ projectNumber: 48,
274
+ }),
275
+ ).toEqual('https://github.com/users/HiromiShikata/projects/48');
276
+ });
277
+ });
@@ -0,0 +1,129 @@
1
+ import ky from 'ky';
2
+ import { BaseGitHubRepository } from './BaseGitHubRepository';
3
+ import {
4
+ ProjectDefinition,
5
+ ProjectFieldDefinition,
6
+ convertToFieldOptionColor,
7
+ projectFromDefinition,
8
+ } from './projectFieldDefinition';
9
+ import { Project } from '../../domain/entities/Project';
10
+
11
+ export type ProjectLocation = {
12
+ owner: string;
13
+ ownerType: 'users' | 'orgs';
14
+ projectNumber: number;
15
+ };
16
+
17
+ type RestProjectResponse = {
18
+ id: number;
19
+ node_id: string;
20
+ title: string;
21
+ };
22
+
23
+ type RestProjectFieldResponse = {
24
+ id: number;
25
+ node_id: string;
26
+ name: string;
27
+ options?: {
28
+ id: string;
29
+ name: { raw: string };
30
+ description: { raw: string };
31
+ color: string;
32
+ }[];
33
+ };
34
+
35
+ const isNotFoundResponse = (error: unknown): boolean => {
36
+ if (typeof error !== 'object' || error === null || !('response' in error)) {
37
+ return false;
38
+ }
39
+ const response = error.response;
40
+ return (
41
+ typeof response === 'object' &&
42
+ response !== null &&
43
+ 'status' in response &&
44
+ response.status === 404
45
+ );
46
+ };
47
+
48
+ export const projectUrlFromLocation = (location: ProjectLocation): string =>
49
+ `https://github.com/${location.ownerType}/${location.owner}/projects/${location.projectNumber}`;
50
+
51
+ export const projectLocationFromUrl = (
52
+ projectUrl: string,
53
+ ): ProjectLocation | null => {
54
+ const match = projectUrl.match(
55
+ /https:\/\/github\.com\/(users|orgs)\/([^/]+)\/projects\/(\d+)/,
56
+ );
57
+ if (!match) {
58
+ return null;
59
+ }
60
+ const [, ownerType, owner, projectNumberText] = match;
61
+ return {
62
+ owner,
63
+ ownerType: ownerType === 'orgs' ? 'orgs' : 'users',
64
+ projectNumber: parseInt(projectNumberText, 10),
65
+ };
66
+ };
67
+
68
+ export class RestProjectRepository extends BaseGitHubRepository {
69
+ private projectApiUrl = (location: ProjectLocation): string =>
70
+ `https://api.github.com/${location.ownerType}/${location.owner}/projectsV2/${location.projectNumber}`;
71
+
72
+ private requestHeaders = (): Record<string, string> => ({
73
+ Authorization: `token ${this.ghToken}`,
74
+ Accept: 'application/vnd.github+json',
75
+ });
76
+
77
+ listFieldDefinitions = async (
78
+ location: ProjectLocation,
79
+ ): Promise<ProjectFieldDefinition[]> => {
80
+ const fields = await ky
81
+ .get(`${this.projectApiUrl(location)}/fields`, {
82
+ searchParams: { per_page: 100 },
83
+ headers: this.requestHeaders(),
84
+ })
85
+ .json<RestProjectFieldResponse[]>();
86
+ return fields.map((field) => ({
87
+ fieldId: field.node_id,
88
+ databaseId: field.id,
89
+ name: field.name,
90
+ options: (field.options ?? []).map((option) => ({
91
+ id: option.id,
92
+ name: option.name.raw,
93
+ color: convertToFieldOptionColor(option.color),
94
+ description: option.description.raw,
95
+ })),
96
+ }));
97
+ };
98
+
99
+ listFieldNames = async (location: ProjectLocation): Promise<string[]> => {
100
+ const fields = await this.listFieldDefinitions(location);
101
+ return fields.map((field) => field.name);
102
+ };
103
+
104
+ getProject = async (location: ProjectLocation): Promise<Project | null> => {
105
+ let project: RestProjectResponse;
106
+ let fields: ProjectFieldDefinition[];
107
+ try {
108
+ [project, fields] = await Promise.all([
109
+ ky
110
+ .get(this.projectApiUrl(location), { headers: this.requestHeaders() })
111
+ .json<RestProjectResponse>(),
112
+ this.listFieldDefinitions(location),
113
+ ]);
114
+ } catch (error) {
115
+ if (isNotFoundResponse(error)) {
116
+ return null;
117
+ }
118
+ throw error;
119
+ }
120
+ const definition: ProjectDefinition = {
121
+ id: project.node_id,
122
+ url: projectUrlFromLocation(location),
123
+ databaseId: project.id,
124
+ name: project.title,
125
+ fields,
126
+ };
127
+ return projectFromDefinition(definition);
128
+ };
129
+ }