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
|
@@ -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
|
+
}
|
|
@@ -1668,6 +1668,64 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
1668
1668
|
});
|
|
1669
1669
|
});
|
|
1670
1670
|
|
|
1671
|
+
describe('getIssueBodyByUrl', () => {
|
|
1672
|
+
afterEach(() => {
|
|
1673
|
+
jest.restoreAllMocks();
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1676
|
+
it('reads the body over the REST issue endpoint', async () => {
|
|
1677
|
+
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
1678
|
+
new Response(JSON.stringify({ body: 'story issue body' }), {
|
|
1679
|
+
status: 200,
|
|
1680
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1681
|
+
}),
|
|
1682
|
+
);
|
|
1683
|
+
|
|
1684
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
1685
|
+
const result = await repository.getIssueBodyByUrl(
|
|
1686
|
+
'https://github.com/HiromiShikata/test-repository/issues/42',
|
|
1687
|
+
);
|
|
1688
|
+
|
|
1689
|
+
expect(result).toBe('story issue body');
|
|
1690
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
1691
|
+
'https://api.github.com/repos/HiromiShikata/test-repository/issues/42',
|
|
1692
|
+
expect.objectContaining({ method: 'GET' }),
|
|
1693
|
+
);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
it('returns null when the issue no longer exists', async () => {
|
|
1697
|
+
jest.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
1698
|
+
new Response('Not Found', {
|
|
1699
|
+
status: 404,
|
|
1700
|
+
statusText: 'Not Found',
|
|
1701
|
+
}),
|
|
1702
|
+
);
|
|
1703
|
+
|
|
1704
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
1705
|
+
const result = await repository.getIssueBodyByUrl(
|
|
1706
|
+
'https://github.com/HiromiShikata/test-repository/issues/42',
|
|
1707
|
+
);
|
|
1708
|
+
|
|
1709
|
+
expect(result).toBeNull();
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
it('throws when the API fails for a reason other than the issue being absent', async () => {
|
|
1713
|
+
jest.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
1714
|
+
new Response('Bad Gateway', {
|
|
1715
|
+
status: 502,
|
|
1716
|
+
statusText: 'Bad Gateway',
|
|
1717
|
+
}),
|
|
1718
|
+
);
|
|
1719
|
+
|
|
1720
|
+
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
1721
|
+
await expect(
|
|
1722
|
+
repository.getIssueBodyByUrl(
|
|
1723
|
+
'https://github.com/HiromiShikata/test-repository/issues/42',
|
|
1724
|
+
),
|
|
1725
|
+
).rejects.toThrow('502');
|
|
1726
|
+
});
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1671
1729
|
describe('getIssueOrPullRequestComments', () => {
|
|
1672
1730
|
afterEach(() => {
|
|
1673
1731
|
jest.restoreAllMocks();
|
|
@@ -467,6 +467,7 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
467
467
|
RestIssueRepository,
|
|
468
468
|
| 'createNewIssue'
|
|
469
469
|
| 'updateIssue'
|
|
470
|
+
| 'updateIssueBody'
|
|
470
471
|
| 'createComment'
|
|
471
472
|
| 'getIssue'
|
|
472
473
|
| 'updateLabels'
|
|
@@ -803,6 +804,12 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
803
804
|
updateIssue = async (issue: Issue): Promise<void> => {
|
|
804
805
|
await this.restIssueRepository.updateIssue(issue);
|
|
805
806
|
};
|
|
807
|
+
updateIssueBody = async (
|
|
808
|
+
issue: Pick<Issue, 'org' | 'repo' | 'number'>,
|
|
809
|
+
body: string,
|
|
810
|
+
): Promise<void> => {
|
|
811
|
+
await this.restIssueRepository.updateIssueBody(issue, body);
|
|
812
|
+
};
|
|
806
813
|
getIssueByUrl = async (url: string): Promise<Issue | null> => {
|
|
807
814
|
const projectItem =
|
|
808
815
|
await this.graphqlProjectItemRepository.fetchProjectItemByUrl(url);
|
|
@@ -2296,9 +2303,9 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
2296
2303
|
await this.restIssueRepository.createComment(issueOrPrUrl, commentBody);
|
|
2297
2304
|
};
|
|
2298
2305
|
|
|
2299
|
-
|
|
2306
|
+
private fetchIssueBodyResponse = (url: string): Promise<Response> => {
|
|
2300
2307
|
const { owner, repo, issueNumber } = this.parseIssueUrl(url);
|
|
2301
|
-
|
|
2308
|
+
return this.fetchWithRateLimitRetry(() =>
|
|
2302
2309
|
fetch(
|
|
2303
2310
|
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`,
|
|
2304
2311
|
{
|
|
@@ -2310,10 +2317,12 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
2310
2317
|
},
|
|
2311
2318
|
),
|
|
2312
2319
|
);
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2320
|
+
};
|
|
2321
|
+
|
|
2322
|
+
private parseIssueBodyResponse = async (
|
|
2323
|
+
url: string,
|
|
2324
|
+
response: Response,
|
|
2325
|
+
): Promise<string> => {
|
|
2317
2326
|
const body: unknown = await response.json();
|
|
2318
2327
|
if (!isIssueOrPullRequestBodyResponse(body)) {
|
|
2319
2328
|
throw new Error(
|
|
@@ -2323,6 +2332,27 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
2323
2332
|
return body.body ?? '';
|
|
2324
2333
|
};
|
|
2325
2334
|
|
|
2335
|
+
getIssueOrPullRequestBody = async (url: string): Promise<string> => {
|
|
2336
|
+
const response = await this.fetchIssueBodyResponse(url);
|
|
2337
|
+
if (!response.ok) {
|
|
2338
|
+
const reason = await this.formatGitHubErrorWithStatus(response);
|
|
2339
|
+
throw new Error(`Failed to fetch body for ${url}: ${reason}`);
|
|
2340
|
+
}
|
|
2341
|
+
return this.parseIssueBodyResponse(url, response);
|
|
2342
|
+
};
|
|
2343
|
+
|
|
2344
|
+
getIssueBodyByUrl = async (url: string): Promise<string | null> => {
|
|
2345
|
+
const response = await this.fetchIssueBodyResponse(url);
|
|
2346
|
+
if (response.status === 404) {
|
|
2347
|
+
return null;
|
|
2348
|
+
}
|
|
2349
|
+
if (!response.ok) {
|
|
2350
|
+
const reason = await this.formatGitHubErrorWithStatus(response);
|
|
2351
|
+
throw new Error(`Failed to fetch body for ${url}: ${reason}`);
|
|
2352
|
+
}
|
|
2353
|
+
return this.parseIssueBodyResponse(url, response);
|
|
2354
|
+
};
|
|
2355
|
+
|
|
2326
2356
|
getIssueOrPullRequestComments = async (
|
|
2327
2357
|
url: string,
|
|
2328
2358
|
): Promise<IssueComment[]> => {
|
|
@@ -263,6 +263,25 @@ describe('RestIssueRepository', () => {
|
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
265
|
|
|
266
|
+
describe('updateIssueBody', () => {
|
|
267
|
+
it('sends only the body so the title, labels, assignees and state are left untouched', async () => {
|
|
268
|
+
const issue = buildIssue();
|
|
269
|
+
|
|
270
|
+
mockPatch.mockResolvedValue(undefined);
|
|
271
|
+
|
|
272
|
+
await restIssueRepository.updateIssueBody(issue, 'rewritten body');
|
|
273
|
+
|
|
274
|
+
expect(mockPatch).toHaveBeenCalledTimes(1);
|
|
275
|
+
expect(mockPatch).toHaveBeenCalledWith(
|
|
276
|
+
'https://api.github.com/repos/HiromiShikata/test-repository/issues/40',
|
|
277
|
+
{
|
|
278
|
+
json: { body: 'rewritten body' },
|
|
279
|
+
headers: { Authorization: 'token dummy-token' },
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
266
285
|
describe('searchIssues', () => {
|
|
267
286
|
const buildSearchItem = (
|
|
268
287
|
overrides: Partial<{
|
|
@@ -105,6 +105,19 @@ export class RestIssueRepository
|
|
|
105
105
|
);
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
+
updateIssueBody = async (
|
|
109
|
+
issue: Pick<Issue, 'org' | 'repo' | 'number'>,
|
|
110
|
+
body: string,
|
|
111
|
+
): Promise<void> => {
|
|
112
|
+
await ky.patch(
|
|
113
|
+
`https://api.github.com/repos/${issue.org}/${issue.repo}/issues/${issue.number}`,
|
|
114
|
+
{
|
|
115
|
+
json: { body },
|
|
116
|
+
headers: { Authorization: `token ${this.ghToken}` },
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
};
|
|
120
|
+
|
|
108
121
|
updateLabels = async (
|
|
109
122
|
issue: Issue,
|
|
110
123
|
labels: Issue['labels'],
|