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
@@ -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
- getIssueOrPullRequestBody = async (url: string): Promise<string> => {
2306
+ private fetchIssueBodyResponse = (url: string): Promise<Response> => {
2300
2307
  const { owner, repo, issueNumber } = this.parseIssueUrl(url);
2301
- const response = await this.fetchWithRateLimitRetry(() =>
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
- if (!response.ok) {
2314
- const reason = await this.formatGitHubErrorWithStatus(response);
2315
- throw new Error(`Failed to fetch body for ${url}: ${reason}`);
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'],