github-issue-tower-defence-management 1.44.10 → 1.46.0

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 (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/adapter/entry-points/cli/index.js +0 -3
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +0 -2
  5. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +0 -2
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/repositories/BaseGitHubRepository.js +5 -22
  9. package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
  10. package/bin/adapter/repositories/GraphqlProjectRepository.js +40 -0
  11. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  12. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +36 -1
  13. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  14. package/bin/domain/usecases/StartPreparationUseCase.js +17 -2
  15. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  16. package/package.json +1 -2
  17. package/src/adapter/entry-points/cli/index.test.ts +0 -3
  18. package/src/adapter/entry-points/cli/index.ts +2 -3
  19. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +0 -6
  20. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +0 -2
  21. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +0 -6
  22. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +0 -2
  23. package/src/adapter/repositories/BaseGitHubRepository.test.ts +3 -48
  24. package/src/adapter/repositories/BaseGitHubRepository.ts +5 -33
  25. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +72 -0
  26. package/src/adapter/repositories/GraphqlProjectRepository.ts +57 -1
  27. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +58 -3
  28. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +1 -0
  29. package/src/domain/usecases/StartPreparationUseCase.test.ts +125 -7
  30. package/src/domain/usecases/StartPreparationUseCase.ts +32 -3
  31. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +7 -0
  32. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  33. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  34. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  35. package/types/adapter/repositories/BaseGitHubRepository.d.ts +0 -1
  36. package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
  37. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +5 -2
  38. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  39. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +3 -0
  40. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  41. package/types/domain/usecases/StartPreparationUseCase.d.ts +1 -1
  42. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  43. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +4 -0
  44. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  45. package/bin/adapter/repositories/CheerioProjectRepository.js +0 -45
  46. package/bin/adapter/repositories/CheerioProjectRepository.js.map +0 -1
  47. package/src/adapter/repositories/CheerioProjectRepository.test.ts +0 -122
  48. package/src/adapter/repositories/CheerioProjectRepository.ts +0 -65
  49. package/types/adapter/repositories/CheerioProjectRepository.d.ts +0 -17
  50. package/types/adapter/repositories/CheerioProjectRepository.d.ts.map +0 -1
@@ -25,7 +25,6 @@ import { RestIssueRepository } from '../../repositories/issue/RestIssueRepositor
25
25
  import { GraphqlProjectItemRepository } from '../../repositories/issue/GraphqlProjectItemRepository';
26
26
  import { ApiV3CheerioRestIssueRepository } from '../../repositories/issue/ApiV3CheerioRestIssueRepository';
27
27
  import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
28
- import { CheerioProjectRepository } from '../../repositories/CheerioProjectRepository';
29
28
  import { BaseGitHubRepository } from '../../repositories/BaseGitHubRepository';
30
29
  import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
31
30
  import { OauthAPIProxyClaudeRepository } from '../../repositories/OauthAPIProxyClaudeRepository';
@@ -257,7 +256,7 @@ program
257
256
  );
258
257
  const projectRepository = {
259
258
  ...new GraphqlProjectRepository(...githubRepositoryParams),
260
- ...new CheerioProjectRepository(...githubRepositoryParams),
259
+
261
260
  prepareStatus: async (
262
261
  _name: string,
263
262
  project: Project,
@@ -490,7 +489,7 @@ program
490
489
  );
491
490
  const projectRepository = {
492
491
  ...new GraphqlProjectRepository(...githubRepositoryParams),
493
- ...new CheerioProjectRepository(...githubRepositoryParams),
492
+
494
493
  prepareStatus: async (
495
494
  _name: string,
496
495
  project: Project,
@@ -2,7 +2,6 @@ import fs from 'fs';
2
2
  import YAML from 'yaml';
3
3
 
4
4
  jest.mock('fs');
5
- jest.mock('gh-cookie', () => ({ getCookieContent: jest.fn() }));
6
5
  jest.mock('../../repositories/LocalStorageRepository');
7
6
  jest.mock('../../repositories/GraphqlProjectRepository');
8
7
  jest.mock('../../repositories/issue/ApiV3IssueRepository');
@@ -11,7 +10,6 @@ jest.mock('../../repositories/issue/GraphqlProjectItemRepository');
11
10
  jest.mock('../../repositories/issue/ApiV3CheerioRestIssueRepository');
12
11
  jest.mock('../../repositories/LocalStorageCacheRepository');
13
12
  jest.mock('../../repositories/BaseGitHubRepository');
14
- jest.mock('../../repositories/CheerioProjectRepository');
15
13
 
16
14
  const mockRun = jest.fn().mockResolvedValue({
17
15
  project: {},
@@ -28,14 +26,12 @@ jest.mock('../../../domain/usecases/GetStoryObjectMapUseCase', () => ({
28
26
 
29
27
  import { GetStoryObjectMapUseCaseHandler } from './GetStoryObjectMapUseCaseHandler';
30
28
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
31
- import { CheerioProjectRepository } from '../../repositories/CheerioProjectRepository';
32
29
  import { ApiV3IssueRepository } from '../../repositories/issue/ApiV3IssueRepository';
33
30
  import { RestIssueRepository } from '../../repositories/issue/RestIssueRepository';
34
31
  import { GraphqlProjectItemRepository } from '../../repositories/issue/GraphqlProjectItemRepository';
35
32
  import { ApiV3CheerioRestIssueRepository } from '../../repositories/issue/ApiV3CheerioRestIssueRepository';
36
33
 
37
34
  const MockedGraphqlProjectRepository = jest.mocked(GraphqlProjectRepository);
38
- const MockedCheerioProjectRepository = jest.mocked(CheerioProjectRepository);
39
35
  const MockedApiV3IssueRepository = jest.mocked(ApiV3IssueRepository);
40
36
  const MockedRestIssueRepository = jest.mocked(RestIssueRepository);
41
37
  const MockedGraphqlProjectItemRepository = jest.mocked(
@@ -122,7 +118,6 @@ describe('GetStoryObjectMapUseCaseHandler', () => {
122
118
 
123
119
  for (const MockedClass of [
124
120
  MockedGraphqlProjectRepository,
125
- MockedCheerioProjectRepository,
126
121
  MockedApiV3IssueRepository,
127
122
  MockedRestIssueRepository,
128
123
  MockedGraphqlProjectItemRepository,
@@ -159,7 +154,6 @@ describe('GetStoryObjectMapUseCaseHandler', () => {
159
154
 
160
155
  for (const MockedClass of [
161
156
  MockedGraphqlProjectRepository,
162
- MockedCheerioProjectRepository,
163
157
  MockedApiV3IssueRepository,
164
158
  MockedRestIssueRepository,
165
159
  MockedGraphqlProjectItemRepository,
@@ -11,7 +11,6 @@ import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCach
11
11
  import { Issue } from '../../../domain/entities/Issue';
12
12
  import { Project } from '../../../domain/entities/Project';
13
13
  import { BaseGitHubRepository } from '../../repositories/BaseGitHubRepository';
14
- import { CheerioProjectRepository } from '../../repositories/CheerioProjectRepository';
15
14
  import { GetStoryObjectMapUseCase } from '../../../domain/usecases/GetStoryObjectMapUseCase';
16
15
  import { StoryObjectMap } from '../../../domain/entities/StoryObjectMap';
17
16
 
@@ -65,7 +64,6 @@ export class GetStoryObjectMapUseCaseHandler {
65
64
  ];
66
65
  const projectRepository = {
67
66
  ...new GraphqlProjectRepository(...githubRepositoryParams),
68
- ...new CheerioProjectRepository(...githubRepositoryParams),
69
67
  };
70
68
  const apiV3IssueRepository = new ApiV3IssueRepository(
71
69
  ...githubRepositoryParams,
@@ -3,12 +3,10 @@ import YAML from 'yaml';
3
3
  import type { HandleScheduledEventUseCase } from '../../../domain/usecases/HandleScheduledEventUseCase';
4
4
 
5
5
  jest.mock('fs');
6
- jest.mock('gh-cookie', () => ({ getCookieContent: jest.fn() }));
7
6
  jest.mock('../../repositories/SystemDateRepository');
8
7
  jest.mock('../../repositories/LocalStorageRepository');
9
8
  jest.mock('../../repositories/GoogleSpreadsheetRepository');
10
9
  jest.mock('../../repositories/GraphqlProjectRepository');
11
- jest.mock('../../repositories/CheerioProjectRepository');
12
10
  jest.mock('../../repositories/issue/ApiV3IssueRepository');
13
11
  jest.mock('../../repositories/issue/RestIssueRepository');
14
12
  jest.mock('../../repositories/issue/GraphqlProjectItemRepository');
@@ -118,14 +116,12 @@ jest.mock('../../repositories/FetchWebhookRepository', () => ({
118
116
 
119
117
  import { HandleScheduledEventUseCaseHandler } from './HandleScheduledEventUseCaseHandler';
120
118
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
121
- import { CheerioProjectRepository } from '../../repositories/CheerioProjectRepository';
122
119
  import { ApiV3IssueRepository } from '../../repositories/issue/ApiV3IssueRepository';
123
120
  import { RestIssueRepository } from '../../repositories/issue/RestIssueRepository';
124
121
  import { GraphqlProjectItemRepository } from '../../repositories/issue/GraphqlProjectItemRepository';
125
122
  import { ApiV3CheerioRestIssueRepository } from '../../repositories/issue/ApiV3CheerioRestIssueRepository';
126
123
 
127
124
  const MockedGraphqlProjectRepository = jest.mocked(GraphqlProjectRepository);
128
- const MockedCheerioProjectRepository = jest.mocked(CheerioProjectRepository);
129
125
  const MockedApiV3IssueRepository = jest.mocked(ApiV3IssueRepository);
130
126
  const MockedRestIssueRepository = jest.mocked(RestIssueRepository);
131
127
  const MockedGraphqlProjectItemRepository = jest.mocked(
@@ -211,7 +207,6 @@ describe('HandleScheduledEventUseCaseHandler', () => {
211
207
 
212
208
  for (const MockedClass of [
213
209
  MockedGraphqlProjectRepository,
214
- MockedCheerioProjectRepository,
215
210
  MockedApiV3IssueRepository,
216
211
  MockedRestIssueRepository,
217
212
  MockedGraphqlProjectItemRepository,
@@ -248,7 +243,6 @@ describe('HandleScheduledEventUseCaseHandler', () => {
248
243
 
249
244
  for (const MockedClass of [
250
245
  MockedGraphqlProjectRepository,
251
- MockedCheerioProjectRepository,
252
246
  MockedApiV3IssueRepository,
253
247
  MockedRestIssueRepository,
254
248
  MockedGraphqlProjectItemRepository,
@@ -29,7 +29,6 @@ import { ConvertCheckboxToIssueInStoryIssueUseCase } from '../../../domain/useca
29
29
  import { ChangeStatusByStoryColorUseCase } from '../../../domain/usecases/ChangeStatusByStoryColorUseCase';
30
30
  import { SetNoStoryIssueToStoryUseCase } from '../../../domain/usecases/SetNoStoryIssueToStoryUseCase';
31
31
  import { CreateNewStoryByLabelUseCase } from '../../../domain/usecases/CreateNewStoryByLabelUseCase';
32
- import { CheerioProjectRepository } from '../../repositories/CheerioProjectRepository';
33
32
  import { ProjectRepository } from '../../../domain/usecases/adapter-interfaces/ProjectRepository';
34
33
  import { AssignNoAssigneeIssueToManagerUseCase } from '../../../domain/usecases/AssignNoAssigneeIssueToManagerUseCase';
35
34
  import { UpdateIssueStatusByLabelUseCase } from '../../../domain/usecases/UpdateIssueStatusByLabelUseCase';
@@ -177,7 +176,6 @@ export class HandleScheduledEventUseCaseHandler {
177
176
  ];
178
177
  const projectRepository: ProjectRepository = {
179
178
  ...new GraphqlProjectRepository(...githubRepositoryParams),
180
- ...new CheerioProjectRepository(...githubRepositoryParams),
181
179
  prepareStatus: async (
182
180
  _name: string,
183
181
  project: Project,
@@ -19,12 +19,6 @@ import fs from 'fs';
19
19
  import { BaseGitHubRepository } from './BaseGitHubRepository';
20
20
  import resetAllMocks = jest.resetAllMocks;
21
21
  import { LocalStorageRepository } from './LocalStorageRepository';
22
-
23
- const mockGetCookieContent = jest.fn<Promise<unknown>, unknown[]>();
24
- jest.mock('gh-cookie', () => ({
25
- getCookieContent: (...args: unknown[]): Promise<unknown> =>
26
- mockGetCookieContent(...args),
27
- }));
28
22
  describe('BaseGitHubRepository', () => {
29
23
  const jsonFilePath = './tmp/github.com.cookies.json';
30
24
  const localStorageRepository = new LocalStorageRepository();
@@ -133,7 +127,6 @@ describe('BaseGitHubRepository', () => {
133
127
  'dummy-password',
134
128
  'dummy-authenticator-key',
135
129
  );
136
- this.cookieRefreshRetryDelayMs = 0;
137
130
  }
138
131
  }
139
132
 
@@ -153,8 +146,6 @@ describe('BaseGitHubRepository', () => {
153
146
  beforeEach(() => {
154
147
  mockKyGet.mockReset().mockReturnValue({ text: mockKyGetText });
155
148
  mockKyGetText.mockReset();
156
- mockGetCookieContent.mockReset();
157
- mockGetCookieContent.mockResolvedValue(validCookieJson);
158
149
  fs.writeFileSync(refreshCookieJsonFilePath, validCookieJson);
159
150
  });
160
151
 
@@ -182,10 +173,7 @@ describe('BaseGitHubRepository', () => {
182
173
  it('should fail when HTML contains username in content but not in user-login meta tag (not logged in)', async () => {
183
174
  const repository = new RefreshTestRepository();
184
175
  const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body><h1>${ghUserName}</h1><p>Public profile</p></body></html>`;
185
- mockKyGetText
186
- .mockResolvedValueOnce(notLoggedInHtml)
187
- .mockResolvedValueOnce(notLoggedInHtml)
188
- .mockResolvedValueOnce(notLoggedInHtml);
176
+ mockKyGetText.mockResolvedValueOnce(notLoggedInHtml);
189
177
 
190
178
  await expect(repository.refreshCookie()).rejects.toThrow(
191
179
  'Failed to refresh cookie',
@@ -210,47 +198,14 @@ describe('BaseGitHubRepository', () => {
210
198
  );
211
199
  });
212
200
 
213
- it('should throw when all three attempts fail', async () => {
201
+ it('should throw when the authentication check fails', async () => {
214
202
  const repository = new RefreshTestRepository();
215
203
  const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body></body></html>`;
216
- mockKyGetText
217
- .mockResolvedValueOnce(notLoggedInHtml)
218
- .mockResolvedValueOnce(notLoggedInHtml)
219
- .mockResolvedValueOnce(notLoggedInHtml);
204
+ mockKyGetText.mockResolvedValueOnce(notLoggedInHtml);
220
205
 
221
206
  await expect(repository.refreshCookie()).rejects.toThrow(
222
207
  'Failed to refresh cookie',
223
208
  );
224
209
  });
225
-
226
- it('should reset cookie cache before regenerating so new cookie is used', async () => {
227
- const repository = new RefreshTestRepository();
228
- mockKyGetText
229
- .mockResolvedValueOnce(
230
- `<html><head><meta name="user-login" content=""></head><body></body></html>`,
231
- )
232
- .mockResolvedValueOnce(
233
- `<html><head><meta name="user-login" content="${ghUserName}"></head><body></body></html>`,
234
- );
235
-
236
- await expect(repository.refreshCookie()).resolves.toBeUndefined();
237
- expect(mockKyGet).toHaveBeenCalledTimes(2);
238
- expect(mockGetCookieContent).toHaveBeenCalledTimes(1);
239
- });
240
-
241
- it('should succeed on third attempt after two failed cookie refresh attempts', async () => {
242
- const repository = new RefreshTestRepository();
243
- const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body></body></html>`;
244
- mockKyGetText
245
- .mockResolvedValueOnce(notLoggedInHtml)
246
- .mockResolvedValueOnce(notLoggedInHtml)
247
- .mockResolvedValueOnce(
248
- `<html><head><meta name="user-login" content="${ghUserName}"></head><body></body></html>`,
249
- );
250
-
251
- await expect(repository.refreshCookie()).resolves.toBeUndefined();
252
- expect(mockKyGet).toHaveBeenCalledTimes(3);
253
- expect(mockGetCookieContent).toHaveBeenCalledTimes(2);
254
- });
255
210
  });
256
211
  });
@@ -1,6 +1,5 @@
1
1
  import { promises as fsPromises } from 'fs';
2
2
  import { serialize } from 'cookie';
3
- import { getCookieContent } from 'gh-cookie';
4
3
  import fs from 'fs';
5
4
  import { LocalStorageRepository } from './LocalStorageRepository';
6
5
  import ky from 'ky';
@@ -18,7 +17,6 @@ interface Cookie {
18
17
 
19
18
  export class BaseGitHubRepository {
20
19
  cookie: string | null;
21
- protected cookieRefreshRetryDelayMs = 3000;
22
20
  constructor(
23
21
  readonly localStorageRepository: LocalStorageRepository,
24
22
  readonly jsonFilePath: string = './tmp/github.com.cookies.json',
@@ -82,19 +80,7 @@ export class BaseGitHubRepository {
82
80
  };
83
81
  protected createCookieStringFromFile = async (): Promise<string> => {
84
82
  if (!fs.existsSync(this.jsonFilePath)) {
85
- if (
86
- !this.ghUserName ||
87
- !this.ghUserPassword ||
88
- !this.ghAuthenticatorKey
89
- ) {
90
- throw new Error('No cookie file and no credentials provided');
91
- }
92
- const cookie = await getCookieContent(
93
- this.ghUserName,
94
- this.ghUserPassword,
95
- this.ghAuthenticatorKey,
96
- );
97
- this.localStorageRepository.write(this.jsonFilePath, cookie);
83
+ throw new Error('No cookie file found');
98
84
  }
99
85
  const data = await fsPromises.readFile(this.jsonFilePath, {
100
86
  encoding: 'utf-8',
@@ -169,24 +155,10 @@ export class BaseGitHubRepository {
169
155
  );
170
156
  }
171
157
  const profileUrl = `https://github.com/${this.ghUserName}`;
172
- const maxAttempts = 3;
173
-
174
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
175
- if (attempt > 0) {
176
- await new Promise<void>((resolve) =>
177
- setTimeout(resolve, this.cookieRefreshRetryDelayMs),
178
- );
179
- this.localStorageRepository.remove(this.jsonFilePath);
180
- this.cookie = null;
181
- }
182
- const headers = await this.createHeader();
183
- const html = await ky.get(profileUrl, { headers }).text();
184
- if (
185
- html.includes(`meta name="user-login" content="${this.ghUserName}"`)
186
- ) {
187
- return;
188
- }
158
+ const headers = await this.createHeader();
159
+ const html = await ky.get(profileUrl, { headers }).text();
160
+ if (!html.includes(`meta name="user-login" content="${this.ghUserName}"`)) {
161
+ throw new Error('Failed to refresh cookie');
189
162
  }
190
- throw new Error('Failed to refresh cookie');
191
163
  };
192
164
  }
@@ -1,5 +1,6 @@
1
1
  import { GraphqlProjectRepository } from './GraphqlProjectRepository';
2
2
  import { LocalStorageRepository } from './LocalStorageRepository';
3
+ import { FieldOption, Project } from '../../domain/entities/Project';
3
4
 
4
5
  describe('GraphqlProjectRepository', () => {
5
6
  const localStorageRepository = new LocalStorageRepository();
@@ -29,6 +30,77 @@ describe('GraphqlProjectRepository', () => {
29
30
  });
30
31
  });
31
32
 
33
+ describe('updateStoryList', () => {
34
+ const storyFieldId = 'PVTSSF_lAHOAGJHa84AFhgFzg1oBms';
35
+ const existingStories: FieldOption[] = [
36
+ { id: 'af410dae', name: 'story1', color: 'GRAY', description: '' },
37
+ {
38
+ id: '696ccdef',
39
+ name: 'Workflow Management',
40
+ color: 'GRAY',
41
+ description: '',
42
+ },
43
+ { id: '4fa21881', name: 'test', color: 'GRAY', description: '' },
44
+ ];
45
+ const testProject: Project = {
46
+ id: projectId,
47
+ url: projectUrl,
48
+ databaseId: 1447941,
49
+ name: 'V2 project on owner for testing',
50
+ completionDate50PercentConfidence: null,
51
+ dependedIssueUrlSeparatedByComma: null,
52
+ nextActionDate: {
53
+ fieldId: 'PVTF_lAHOAGJHa84AFhgFzgVlnK4',
54
+ name: 'NextActionDate',
55
+ },
56
+ nextActionHour: null,
57
+ remainingEstimationMinutes: null,
58
+ status: {
59
+ fieldId: 'PVTSSF_lAHOAGJHa84AFhgFzgDLt0c',
60
+ name: 'Status',
61
+ statuses: [],
62
+ },
63
+ story: {
64
+ fieldId: storyFieldId,
65
+ databaseId: 224921195,
66
+ name: 'Story',
67
+ stories: existingStories,
68
+ workflowManagementStory: {
69
+ id: '696ccdef',
70
+ name: 'Workflow Management',
71
+ },
72
+ },
73
+ };
74
+
75
+ it('should add a new option while preserving all existing options', async () => {
76
+ const newOption: Omit<FieldOption, 'id'> & { id: null } = {
77
+ id: null,
78
+ name: 'test-story-from-graphql-unit-test',
79
+ color: 'BLUE',
80
+ description: 'created by graphql unit test',
81
+ };
82
+ const inputList: Parameters<typeof repository.updateStoryList>['1'] = [
83
+ ...existingStories,
84
+ newOption,
85
+ ];
86
+
87
+ const result = await repository.updateStoryList(testProject, inputList);
88
+
89
+ expect(result).toHaveLength(existingStories.length + 1);
90
+ existingStories.forEach((existing) => {
91
+ const found = result.find((r) => r.id === existing.id);
92
+ expect(found).toEqual(existing);
93
+ });
94
+ const added = result.find((r) => r.name === newOption.name);
95
+ expect(added).toBeDefined();
96
+ expect(added?.color).toEqual(newOption.color);
97
+ expect(added?.description).toEqual(newOption.description);
98
+ expect(added?.id).toBeDefined();
99
+
100
+ await repository.updateStoryList(testProject, existingStories);
101
+ });
102
+ });
103
+
32
104
  describe('getProject', () => {
33
105
  it('should retrieve project details', async () => {
34
106
  const project = await repository.getProject(projectId);
@@ -7,7 +7,10 @@ import { normalizeFieldName } from './utils';
7
7
  export class GraphqlProjectRepository
8
8
  extends BaseGitHubRepository
9
9
  implements
10
- Pick<ProjectRepository, 'getProject' | 'findProjectIdByUrl' | 'getByUrl'>
10
+ Pick<
11
+ ProjectRepository,
12
+ 'getProject' | 'findProjectIdByUrl' | 'getByUrl' | 'updateStoryList'
13
+ >
11
14
  {
12
15
  extractProjectFromUrl = (
13
16
  projectUrl: string,
@@ -306,4 +309,57 @@ export class GraphqlProjectRepository
306
309
  }
307
310
  return project;
308
311
  };
312
+ updateStoryList = async (
313
+ project: Project,
314
+ newStoryList: (Omit<FieldOption, 'id'> & {
315
+ id: FieldOption['id'] | null;
316
+ })[],
317
+ ): Promise<FieldOption[]> => {
318
+ if (!project.story) {
319
+ throw new Error('Project has no story field');
320
+ }
321
+ const mutation = `mutation UpdateStoryOptions($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
322
+ updateProjectV2Field(input: {
323
+ fieldId: $fieldId
324
+ singleSelectOptions: $options
325
+ }) {
326
+ projectV2Field {
327
+ ... on ProjectV2SingleSelectField {
328
+ options {
329
+ id
330
+ name
331
+ color
332
+ description
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }`;
338
+ const variables = {
339
+ fieldId: project.story.fieldId,
340
+ options: newStoryList.map(({ id, name, color, description }) => ({
341
+ ...(id !== null ? { id } : {}),
342
+ name,
343
+ color,
344
+ description,
345
+ })),
346
+ };
347
+ const response = await ky
348
+ .post('https://api.github.com/graphql', {
349
+ json: { query: mutation, variables },
350
+ headers: {
351
+ Authorization: `Bearer ${this.ghToken}`,
352
+ },
353
+ })
354
+ .json<{
355
+ data: {
356
+ updateProjectV2Field: {
357
+ projectV2Field: {
358
+ options: FieldOption[];
359
+ };
360
+ };
361
+ };
362
+ }>();
363
+ return response.data.updateProjectV2Field.projectV2Field.options;
364
+ };
309
365
  }
@@ -26,6 +26,7 @@ type TimelineItem = {
26
26
  url?: string;
27
27
  number?: number;
28
28
  state?: string;
29
+ createdAt?: string;
29
30
  mergeable?: string;
30
31
  headRefName?: string;
31
32
  baseRefName?: string;
@@ -693,6 +694,7 @@ export class ApiV3CheerioRestIssueRepository
693
694
  return {
694
695
  url: prUrl,
695
696
  branchName: headRefName ?? null,
697
+ createdAt: new Date(0),
696
698
  isConflicted,
697
699
  isPassedAllCiJob,
698
700
  isCiStateSuccess,
@@ -731,6 +733,7 @@ export class ApiV3CheerioRestIssueRepository
731
733
  url
732
734
  number
733
735
  state
736
+ createdAt
734
737
  mergeable
735
738
  headRefName
736
739
  baseRefName
@@ -855,11 +858,17 @@ export class ApiV3CheerioRestIssueRepository
855
858
  const pr = item.source;
856
859
  const prUrl = pr.url || '';
857
860
  const baseRefName = pr.baseRefName ?? pr.baseRef?.name;
858
-
859
- relatedPRsMap.set(
861
+ const prStatus = this.computePrStatus(
860
862
  prUrl,
861
- this.computePrStatus(prUrl, pr.headRefName, baseRefName, pr),
863
+ pr.headRefName,
864
+ baseRefName,
865
+ pr,
862
866
  );
867
+
868
+ relatedPRsMap.set(prUrl, {
869
+ ...prStatus,
870
+ createdAt: pr.createdAt ? new Date(pr.createdAt) : new Date(0),
871
+ });
863
872
  }
864
873
 
865
874
  hasNextPage = issueData.timelineItems.pageInfo.hasNextPage;
@@ -1017,4 +1026,50 @@ export class ApiV3CheerioRestIssueRepository
1017
1026
 
1018
1027
  return this.computePrStatus(pr.url, pr.headRefName, pr.baseRefName, pr);
1019
1028
  };
1029
+
1030
+ closePullRequest = async (prUrl: string): Promise<void> => {
1031
+ const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
1032
+ const response = await fetch(
1033
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
1034
+ {
1035
+ method: 'PATCH',
1036
+ headers: {
1037
+ Authorization: `Bearer ${this.ghToken}`,
1038
+ 'Content-Type': 'application/json',
1039
+ },
1040
+ body: JSON.stringify({ state: 'closed' }),
1041
+ },
1042
+ );
1043
+ if (!response.ok) {
1044
+ throw new Error(`Failed to close PR ${prUrl}: HTTP ${response.status}`);
1045
+ }
1046
+ };
1047
+
1048
+ deletePullRequestBranch = async (
1049
+ prUrl: string,
1050
+ branchName: string,
1051
+ ): Promise<void> => {
1052
+ const { owner, repo } = this.parseIssueUrl(prUrl);
1053
+ const response = await fetch(
1054
+ `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branchName)}`,
1055
+ {
1056
+ method: 'DELETE',
1057
+ headers: {
1058
+ Authorization: `Bearer ${this.ghToken}`,
1059
+ },
1060
+ },
1061
+ );
1062
+ if (!response.ok && response.status !== 422) {
1063
+ throw new Error(
1064
+ `Failed to delete branch ${branchName} for PR ${prUrl}: HTTP ${response.status}`,
1065
+ );
1066
+ }
1067
+ };
1068
+
1069
+ createCommentByUrl = async (
1070
+ issueOrPrUrl: string,
1071
+ commentBody: string,
1072
+ ): Promise<void> => {
1073
+ await this.restIssueRepository.createComment(issueOrPrUrl, commentBody);
1074
+ };
1020
1075
  }
@@ -82,6 +82,7 @@ const createMockProject = (): Project => ({
82
82
  const createPassingPr = () => ({
83
83
  url: 'https://github.com/user/repo/pull/5',
84
84
  branchName: 'i1',
85
+ createdAt: new Date('2024-01-01T00:00:00Z'),
85
86
  isConflicted: false,
86
87
  isPassedAllCiJob: true,
87
88
  isCiStateSuccess: true,