github-issue-tower-defence-management 1.52.1 → 1.54.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 (40) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +2 -2
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +45 -31
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/domain/entities/WorkflowStatus.js +5 -7
  6. package/bin/domain/entities/WorkflowStatus.js.map +1 -1
  7. package/bin/domain/usecases/HandleScheduledEventUseCase.js +8 -1
  8. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  9. package/bin/domain/usecases/IssueRejectionEvaluator.js +80 -0
  10. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -0
  11. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +4 -67
  12. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  13. package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js +42 -0
  14. package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +1 -0
  15. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +19 -3
  16. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +8 -0
  19. package/src/domain/entities/WorkflowStatus.ts +5 -6
  20. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +58 -0
  21. package/src/domain/usecases/HandleScheduledEventUseCase.ts +11 -0
  22. package/src/domain/usecases/IssueRejectionEvaluator.ts +114 -0
  23. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +15 -89
  24. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +376 -0
  25. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.ts +88 -0
  26. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +177 -9
  27. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +34 -2
  28. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  29. package/types/domain/entities/WorkflowStatus.d.ts +4 -2
  30. package/types/domain/entities/WorkflowStatus.d.ts.map +1 -1
  31. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +6 -1
  32. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  33. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +20 -0
  34. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -0
  35. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -1
  36. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  37. package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts +15 -0
  38. package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts.map +1 -0
  39. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts +2 -0
  40. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
@@ -0,0 +1,376 @@
1
+ import { RevertNotReadyAwaitingQualityCheckUseCase } from './RevertNotReadyAwaitingQualityCheckUseCase';
2
+ import { Issue } from '../entities/Issue';
3
+ import { Project } from '../entities/Project';
4
+
5
+ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
6
+ id: 'project-1',
7
+ url: 'https://github.com/users/user/projects/1',
8
+ databaseId: 1,
9
+ name: 'Test Project',
10
+ status: {
11
+ name: 'Status',
12
+ fieldId: 'field-1',
13
+ statuses: [
14
+ {
15
+ id: 'awaiting-workspace-id',
16
+ name: 'Awaiting Workspace',
17
+ color: 'GRAY',
18
+ description: '',
19
+ },
20
+ {
21
+ id: 'awaiting-quality-check-id',
22
+ name: 'Awaiting Quality Check',
23
+ color: 'BLUE',
24
+ description: '',
25
+ },
26
+ ],
27
+ },
28
+ nextActionDate: null,
29
+ nextActionHour: null,
30
+ story: null,
31
+ remainingEstimationMinutes: null,
32
+ dependedIssueUrlSeparatedByComma: null,
33
+ completionDate50PercentConfidence: null,
34
+ ...overrides,
35
+ });
36
+
37
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
38
+ nameWithOwner: 'user/repo',
39
+ number: 1,
40
+ title: 'Test Issue',
41
+ state: 'OPEN',
42
+ status: 'Awaiting Quality Check',
43
+ story: null,
44
+ nextActionDate: null,
45
+ nextActionHour: null,
46
+ estimationMinutes: null,
47
+ dependedIssueUrls: [],
48
+ completionDate50PercentConfidence: null,
49
+ url: 'https://github.com/user/repo/issues/1',
50
+ assignees: [],
51
+ labels: [],
52
+ org: 'user',
53
+ repo: 'repo',
54
+ body: '',
55
+ itemId: 'item-1',
56
+ isPr: false,
57
+ isInProgress: false,
58
+ isClosed: false,
59
+ createdAt: new Date(),
60
+ author: '',
61
+ ...overrides,
62
+ });
63
+
64
+ const createReadyPr = (url = 'https://github.com/user/repo/pull/1') => ({
65
+ url,
66
+ isConflicted: false,
67
+ isPassedAllCiJob: true,
68
+ isCiStateSuccess: true,
69
+ isResolvedAllReviewComments: true,
70
+ isBranchOutOfDate: false,
71
+ missingRequiredCheckNames: [],
72
+ });
73
+
74
+ describe('RevertNotReadyAwaitingQualityCheckUseCase', () => {
75
+ let mockProjectRepository: {
76
+ findProjectIdByUrl: jest.Mock;
77
+ getProject: jest.Mock;
78
+ };
79
+ let mockIssueRepository: {
80
+ getAllIssues: jest.Mock;
81
+ updateStatus: jest.Mock;
82
+ findRelatedOpenPRs: jest.Mock;
83
+ getOpenPullRequest: jest.Mock;
84
+ };
85
+ let mockIssueCommentRepository: {
86
+ createComment: jest.Mock;
87
+ };
88
+ let mockProject: Project;
89
+ let useCase: RevertNotReadyAwaitingQualityCheckUseCase;
90
+
91
+ beforeEach(() => {
92
+ jest.resetAllMocks();
93
+
94
+ mockProject = createMockProject();
95
+
96
+ mockProjectRepository = {
97
+ findProjectIdByUrl: jest.fn().mockResolvedValue('project-1'),
98
+ getProject: jest.fn().mockResolvedValue(mockProject),
99
+ };
100
+
101
+ mockIssueRepository = {
102
+ getAllIssues: jest
103
+ .fn()
104
+ .mockResolvedValue({ issues: [], cacheUsed: false }),
105
+ updateStatus: jest.fn().mockResolvedValue(undefined),
106
+ findRelatedOpenPRs: jest.fn().mockResolvedValue([]),
107
+ getOpenPullRequest: jest.fn().mockResolvedValue(null),
108
+ };
109
+
110
+ mockIssueCommentRepository = {
111
+ createComment: jest.fn().mockResolvedValue(undefined),
112
+ };
113
+
114
+ useCase = new RevertNotReadyAwaitingQualityCheckUseCase(
115
+ mockProjectRepository,
116
+ mockIssueRepository,
117
+ mockIssueCommentRepository,
118
+ );
119
+ });
120
+
121
+ it('should do nothing when there are no Awaiting Quality Check issues', async () => {
122
+ mockIssueRepository.getAllIssues.mockResolvedValue({
123
+ issues: [
124
+ createMockIssue({ status: 'Awaiting Workspace' }),
125
+ createMockIssue({ status: 'Preparation' }),
126
+ ],
127
+ cacheUsed: false,
128
+ });
129
+
130
+ await useCase.run({
131
+ projectUrl: 'https://github.com/users/user/projects/1',
132
+ allowIssueCacheMinutes: 10,
133
+ });
134
+
135
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
136
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it('should skip Awaiting Quality Check issue with llm-agent label', async () => {
140
+ const issue = createMockIssue({
141
+ status: 'Awaiting Quality Check',
142
+ labels: ['llm-agent'],
143
+ });
144
+ mockIssueRepository.getAllIssues.mockResolvedValue({
145
+ issues: [issue],
146
+ cacheUsed: false,
147
+ });
148
+
149
+ await useCase.run({
150
+ projectUrl: 'https://github.com/users/user/projects/1',
151
+ allowIssueCacheMinutes: 10,
152
+ });
153
+
154
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
155
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('should revert issue when no linked PR is found', async () => {
159
+ const issue = createMockIssue({
160
+ status: 'Awaiting Quality Check',
161
+ });
162
+ mockIssueRepository.getAllIssues.mockResolvedValue({
163
+ issues: [issue],
164
+ cacheUsed: false,
165
+ });
166
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
167
+
168
+ await useCase.run({
169
+ projectUrl: 'https://github.com/users/user/projects/1',
170
+ allowIssueCacheMinutes: 10,
171
+ });
172
+
173
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
174
+ mockProject,
175
+ issue,
176
+ 'awaiting-workspace-id',
177
+ );
178
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
179
+ issue,
180
+ expect.stringContaining('Auto Status Check: REJECTED'),
181
+ );
182
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
183
+ issue,
184
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
185
+ );
186
+ });
187
+
188
+ it('should not revert issue when PR is ready', async () => {
189
+ const issue = createMockIssue({
190
+ status: 'Awaiting Quality Check',
191
+ });
192
+ mockIssueRepository.getAllIssues.mockResolvedValue({
193
+ issues: [issue],
194
+ cacheUsed: false,
195
+ });
196
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([createReadyPr()]);
197
+
198
+ await useCase.run({
199
+ projectUrl: 'https://github.com/users/user/projects/1',
200
+ allowIssueCacheMinutes: 10,
201
+ });
202
+
203
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
204
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it('should revert issue when PR is conflicted', async () => {
208
+ const issue = createMockIssue({
209
+ status: 'Awaiting Quality Check',
210
+ });
211
+ mockIssueRepository.getAllIssues.mockResolvedValue({
212
+ issues: [issue],
213
+ cacheUsed: false,
214
+ });
215
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
216
+ { ...createReadyPr(), isConflicted: true },
217
+ ]);
218
+
219
+ await useCase.run({
220
+ projectUrl: 'https://github.com/users/user/projects/1',
221
+ allowIssueCacheMinutes: 10,
222
+ });
223
+
224
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
225
+ mockProject,
226
+ issue,
227
+ 'awaiting-workspace-id',
228
+ );
229
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
230
+ issue,
231
+ expect.stringContaining('Auto Status Check: REJECTED'),
232
+ );
233
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
234
+ issue,
235
+ expect.stringContaining('PULL_REQUEST_CONFLICTED'),
236
+ );
237
+ });
238
+
239
+ it('should revert issue when CI is failing', async () => {
240
+ const issue = createMockIssue({
241
+ status: 'Awaiting Quality Check',
242
+ });
243
+ mockIssueRepository.getAllIssues.mockResolvedValue({
244
+ issues: [issue],
245
+ cacheUsed: false,
246
+ });
247
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
248
+ {
249
+ ...createReadyPr(),
250
+ isPassedAllCiJob: false,
251
+ isCiStateSuccess: false,
252
+ },
253
+ ]);
254
+
255
+ await useCase.run({
256
+ projectUrl: 'https://github.com/users/user/projects/1',
257
+ allowIssueCacheMinutes: 10,
258
+ });
259
+
260
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
261
+ mockProject,
262
+ issue,
263
+ 'awaiting-workspace-id',
264
+ );
265
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
266
+ issue,
267
+ expect.stringContaining('Auto Status Check: REJECTED'),
268
+ );
269
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
270
+ issue,
271
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
272
+ );
273
+ });
274
+
275
+ it('should revert issue when review comments are not resolved', async () => {
276
+ const issue = createMockIssue({
277
+ status: 'Awaiting Quality Check',
278
+ });
279
+ mockIssueRepository.getAllIssues.mockResolvedValue({
280
+ issues: [issue],
281
+ cacheUsed: false,
282
+ });
283
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
284
+ { ...createReadyPr(), isResolvedAllReviewComments: false },
285
+ ]);
286
+
287
+ await useCase.run({
288
+ projectUrl: 'https://github.com/users/user/projects/1',
289
+ allowIssueCacheMinutes: 10,
290
+ });
291
+
292
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
293
+ mockProject,
294
+ issue,
295
+ 'awaiting-workspace-id',
296
+ );
297
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
298
+ issue,
299
+ expect.stringContaining('Auto Status Check: REJECTED'),
300
+ );
301
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
302
+ issue,
303
+ expect.stringContaining('ANY_REVIEW_COMMENT_NOT_RESOLVED'),
304
+ );
305
+ });
306
+
307
+ it('should revert issue when multiple linked open PRs are found', async () => {
308
+ const issue = createMockIssue({
309
+ status: 'Awaiting Quality Check',
310
+ });
311
+ mockIssueRepository.getAllIssues.mockResolvedValue({
312
+ issues: [issue],
313
+ cacheUsed: false,
314
+ });
315
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
316
+ createReadyPr('https://github.com/user/repo/pull/1'),
317
+ createReadyPr('https://github.com/user/repo/pull/2'),
318
+ ]);
319
+
320
+ await useCase.run({
321
+ projectUrl: 'https://github.com/users/user/projects/1',
322
+ allowIssueCacheMinutes: 10,
323
+ });
324
+
325
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
326
+ mockProject,
327
+ issue,
328
+ 'awaiting-workspace-id',
329
+ );
330
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
331
+ issue,
332
+ expect.stringContaining('Auto Status Check: REJECTED'),
333
+ );
334
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
335
+ issue,
336
+ expect.stringContaining('MULTIPLE_PULL_REQUESTS_FOUND'),
337
+ );
338
+ });
339
+
340
+ it('should revert issue when CI is SUCCESS but required check never started', async () => {
341
+ const issue = createMockIssue({
342
+ status: 'Awaiting Quality Check',
343
+ });
344
+ mockIssueRepository.getAllIssues.mockResolvedValue({
345
+ issues: [issue],
346
+ cacheUsed: false,
347
+ });
348
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
349
+ {
350
+ ...createReadyPr(),
351
+ isPassedAllCiJob: false,
352
+ isCiStateSuccess: true,
353
+ missingRequiredCheckNames: ['E2E Tests'],
354
+ },
355
+ ]);
356
+
357
+ await useCase.run({
358
+ projectUrl: 'https://github.com/users/user/projects/1',
359
+ allowIssueCacheMinutes: 10,
360
+ });
361
+
362
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
363
+ mockProject,
364
+ issue,
365
+ 'awaiting-workspace-id',
366
+ );
367
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
368
+ issue,
369
+ expect.stringContaining('Auto Status Check: REJECTED'),
370
+ );
371
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
372
+ issue,
373
+ expect.stringContaining('REQUIRED_CI_JOB_NEVER_STARTED'),
374
+ );
375
+ });
376
+ });
@@ -0,0 +1,88 @@
1
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
4
+ import { IssueRejectionEvaluator } from './IssueRejectionEvaluator';
5
+ import {
6
+ AWAITING_QUALITY_CHECK_STATUS_NAME,
7
+ AWAITING_WORKSPACE_STATUS_NAME,
8
+ } from '../entities/WorkflowStatus';
9
+
10
+ export class RevertNotReadyAwaitingQualityCheckUseCase {
11
+ private readonly issueRejectionEvaluator: IssueRejectionEvaluator;
12
+
13
+ constructor(
14
+ private readonly projectRepository: Pick<
15
+ ProjectRepository,
16
+ 'findProjectIdByUrl' | 'getProject'
17
+ >,
18
+ private readonly issueRepository: Pick<
19
+ IssueRepository,
20
+ | 'getAllIssues'
21
+ | 'updateStatus'
22
+ | 'findRelatedOpenPRs'
23
+ | 'getOpenPullRequest'
24
+ >,
25
+ private readonly issueCommentRepository: Pick<
26
+ IssueCommentRepository,
27
+ 'createComment'
28
+ >,
29
+ ) {
30
+ this.issueRejectionEvaluator = new IssueRejectionEvaluator(issueRepository);
31
+ }
32
+
33
+ run = async (params: {
34
+ projectUrl: string;
35
+ allowIssueCacheMinutes: number;
36
+ }): Promise<void> => {
37
+ const projectId = await this.projectRepository.findProjectIdByUrl(
38
+ params.projectUrl,
39
+ );
40
+ if (!projectId) {
41
+ throw new Error(`Project not found. projectUrl: ${params.projectUrl}`);
42
+ }
43
+ const project = await this.projectRepository.getProject(projectId);
44
+ if (!project) {
45
+ throw new Error(
46
+ `Project not found. projectId: ${projectId} projectUrl: ${params.projectUrl}`,
47
+ );
48
+ }
49
+
50
+ const awaitingWorkspaceStatusOption = project.status.statuses.find(
51
+ (s) => s.name === AWAITING_WORKSPACE_STATUS_NAME,
52
+ );
53
+ if (!awaitingWorkspaceStatusOption) {
54
+ return;
55
+ }
56
+
57
+ const { issues } = await this.issueRepository.getAllIssues(
58
+ projectId,
59
+ params.allowIssueCacheMinutes,
60
+ );
61
+
62
+ const awaitingQualityCheckIssues = issues.filter(
63
+ (issue) => issue.status === AWAITING_QUALITY_CHECK_STATUS_NAME,
64
+ );
65
+
66
+ for (const issue of awaitingQualityCheckIssues) {
67
+ const hasLlmAgentLabel = issue.labels.some(
68
+ (l) => l === 'llm-agent' || l.startsWith('llm-agent:'),
69
+ );
70
+ if (hasLlmAgentLabel) {
71
+ continue;
72
+ }
73
+
74
+ const { rejections } = await this.issueRejectionEvaluator.evaluate(issue);
75
+ if (rejections.length > 0) {
76
+ await this.issueRepository.updateStatus(
77
+ project,
78
+ issue,
79
+ awaitingWorkspaceStatusOption.id,
80
+ );
81
+ await this.issueCommentRepository.createComment(
82
+ issue,
83
+ `Auto Status Check: REJECTED\n${rejections.map((r) => `- ${r.detail}`).join('\n')}`,
84
+ );
85
+ }
86
+ }
87
+ };
88
+ }
@@ -11,6 +11,8 @@ import {
11
11
  FAILED_PREPARATION_STATUS_NAME,
12
12
  ICEBOX_STATUS_NAME,
13
13
  IN_TMUX_STATUS_NAME,
14
+ LEGACY_IN_TMUX_STATUS_NAME,
15
+ LEGACY_TODO_STATUS_NAME,
14
16
  PC_TODO_STATUS_NAME,
15
17
  PREPARATION_STATUS_NAME,
16
18
  REQUIRED_WORKFLOW_STATUSES,
@@ -44,7 +46,7 @@ const buildCanonicalStatuses = (): FieldOption[] =>
44
46
  }));
45
47
 
46
48
  describe('SetupTowerDefenceProjectUseCase', () => {
47
- it('should define exactly the 11 required statuses in the documented order with the documented colors and no descriptions', () => {
49
+ it('should define exactly the 10 required statuses in the documented order with the documented colors and no descriptions', () => {
48
50
  expect(REQUIRED_WORKFLOW_STATUSES).toEqual([
49
51
  { name: DEFAULT_STATUS_NAME, color: 'ORANGE' },
50
52
  { name: AWAITING_TASK_BREAKDOWN_STATUS_NAME, color: 'ORANGE' },
@@ -53,7 +55,6 @@ describe('SetupTowerDefenceProjectUseCase', () => {
53
55
  { name: FAILED_PREPARATION_STATUS_NAME, color: 'RED' },
54
56
  { name: AWAITING_QUALITY_CHECK_STATUS_NAME, color: 'GREEN' },
55
57
  { name: TODO_STATUS_NAME, color: 'PINK' },
56
- { name: PC_TODO_STATUS_NAME, color: 'PINK' },
57
58
  { name: IN_TMUX_STATUS_NAME, color: 'RED' },
58
59
  { name: DONE_STATUS_NAME, color: 'PURPLE' },
59
60
  { name: ICEBOX_STATUS_NAME, color: 'GRAY' },
@@ -187,12 +188,6 @@ describe('SetupTowerDefenceProjectUseCase', () => {
187
188
  color: 'PINK',
188
189
  description: '',
189
190
  },
190
- {
191
- id: null,
192
- name: PC_TODO_STATUS_NAME,
193
- color: 'PINK',
194
- description: '',
195
- },
196
191
  {
197
192
  id: null,
198
193
  name: IN_TMUX_STATUS_NAME,
@@ -249,7 +244,6 @@ describe('SetupTowerDefenceProjectUseCase', () => {
249
244
  FAILED_PREPARATION_STATUS_NAME,
250
245
  AWAITING_QUALITY_CHECK_STATUS_NAME,
251
246
  TODO_STATUS_NAME,
252
- PC_TODO_STATUS_NAME,
253
247
  IN_TMUX_STATUS_NAME,
254
248
  DONE_STATUS_NAME,
255
249
  ICEBOX_STATUS_NAME,
@@ -272,4 +266,178 @@ describe('SetupTowerDefenceProjectUseCase', () => {
272
266
  const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
273
267
  expect(payload[2].color).toBe(REQUIRED_WORKFLOW_STATUSES[2].color);
274
268
  });
269
+
270
+ it('should rename legacy "Todo" to "Todo by human" by reusing the existing option ID', async () => {
271
+ const mockProjectRepository =
272
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
273
+ const statuses: FieldOption[] = REQUIRED_WORKFLOW_STATUSES.map(
274
+ (required, index) => ({
275
+ id: `id-${index}`,
276
+ name:
277
+ required.name === TODO_STATUS_NAME
278
+ ? LEGACY_TODO_STATUS_NAME
279
+ : required.name,
280
+ color: required.color,
281
+ description: '',
282
+ }),
283
+ );
284
+ const project = buildProject(statuses);
285
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
286
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
287
+
288
+ const useCase = new SetupTowerDefenceProjectUseCase(mockProjectRepository);
289
+ await useCase.run({ projectUrl: project.url });
290
+
291
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
292
+ const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
293
+ const todoEntry = payload.find((s) => s.name === TODO_STATUS_NAME);
294
+ expect(todoEntry).toBeDefined();
295
+ expect(todoEntry?.id).toBe('id-6');
296
+ expect(payload.some((s) => s.name === LEGACY_TODO_STATUS_NAME)).toBe(false);
297
+ });
298
+
299
+ it('should rename legacy "In Tmux" to "In Tmux by human" by reusing the existing option ID', async () => {
300
+ const mockProjectRepository =
301
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
302
+ const statuses: FieldOption[] = REQUIRED_WORKFLOW_STATUSES.map(
303
+ (required, index) => ({
304
+ id: `id-${index}`,
305
+ name:
306
+ required.name === IN_TMUX_STATUS_NAME
307
+ ? LEGACY_IN_TMUX_STATUS_NAME
308
+ : required.name,
309
+ color: required.color,
310
+ description: '',
311
+ }),
312
+ );
313
+ const project = buildProject(statuses);
314
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
315
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
316
+
317
+ const useCase = new SetupTowerDefenceProjectUseCase(mockProjectRepository);
318
+ await useCase.run({ projectUrl: project.url });
319
+
320
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
321
+ const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
322
+ const inTmuxEntry = payload.find((s) => s.name === IN_TMUX_STATUS_NAME);
323
+ expect(inTmuxEntry).toBeDefined();
324
+ expect(inTmuxEntry?.id).toBe('id-7');
325
+ expect(payload.some((s) => s.name === LEGACY_IN_TMUX_STATUS_NAME)).toBe(
326
+ false,
327
+ );
328
+ });
329
+
330
+ it('should remove "PC Todo" from the status list and not include it in others', async () => {
331
+ const mockProjectRepository =
332
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
333
+ const statuses: FieldOption[] = [
334
+ ...buildCanonicalStatuses(),
335
+ {
336
+ id: 'pc-todo-id',
337
+ name: PC_TODO_STATUS_NAME,
338
+ color: 'PINK',
339
+ description: '',
340
+ },
341
+ ];
342
+ const project = buildProject(statuses);
343
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
344
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
345
+
346
+ const useCase = new SetupTowerDefenceProjectUseCase(mockProjectRepository);
347
+ await useCase.run({ projectUrl: project.url });
348
+
349
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
350
+ const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
351
+ expect(payload.some((s) => s.name === PC_TODO_STATUS_NAME)).toBe(false);
352
+ });
353
+
354
+ it('should migrate a project with legacy statuses: rename Todo and In Tmux by ID, remove PC Todo', async () => {
355
+ const mockProjectRepository =
356
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
357
+ const statuses: FieldOption[] = [
358
+ {
359
+ id: 'id-0',
360
+ name: DEFAULT_STATUS_NAME,
361
+ color: 'ORANGE',
362
+ description: '',
363
+ },
364
+ {
365
+ id: 'id-1',
366
+ name: AWAITING_TASK_BREAKDOWN_STATUS_NAME,
367
+ color: 'ORANGE',
368
+ description: '',
369
+ },
370
+ {
371
+ id: 'id-2',
372
+ name: AWAITING_WORKSPACE_STATUS_NAME,
373
+ color: 'BLUE',
374
+ description: '',
375
+ },
376
+ {
377
+ id: 'id-3',
378
+ name: PREPARATION_STATUS_NAME,
379
+ color: 'YELLOW',
380
+ description: '',
381
+ },
382
+ {
383
+ id: 'id-4',
384
+ name: FAILED_PREPARATION_STATUS_NAME,
385
+ color: 'RED',
386
+ description: '',
387
+ },
388
+ {
389
+ id: 'id-5',
390
+ name: AWAITING_QUALITY_CHECK_STATUS_NAME,
391
+ color: 'GREEN',
392
+ description: '',
393
+ },
394
+ {
395
+ id: 'id-6',
396
+ name: LEGACY_TODO_STATUS_NAME,
397
+ color: 'PINK',
398
+ description: '',
399
+ },
400
+ { id: 'id-7', name: PC_TODO_STATUS_NAME, color: 'PINK', description: '' },
401
+ {
402
+ id: 'id-8',
403
+ name: LEGACY_IN_TMUX_STATUS_NAME,
404
+ color: 'RED',
405
+ description: '',
406
+ },
407
+ { id: 'id-9', name: DONE_STATUS_NAME, color: 'PURPLE', description: '' },
408
+ { id: 'id-10', name: ICEBOX_STATUS_NAME, color: 'GRAY', description: '' },
409
+ ];
410
+ const project = buildProject(statuses);
411
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
412
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
413
+
414
+ const useCase = new SetupTowerDefenceProjectUseCase(mockProjectRepository);
415
+ await useCase.run({ projectUrl: project.url });
416
+
417
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
418
+ const [, payload] = mockProjectRepository.updateStatusList.mock.calls[0];
419
+
420
+ expect(payload.map((s) => s.name)).toEqual([
421
+ DEFAULT_STATUS_NAME,
422
+ AWAITING_TASK_BREAKDOWN_STATUS_NAME,
423
+ AWAITING_WORKSPACE_STATUS_NAME,
424
+ PREPARATION_STATUS_NAME,
425
+ FAILED_PREPARATION_STATUS_NAME,
426
+ AWAITING_QUALITY_CHECK_STATUS_NAME,
427
+ TODO_STATUS_NAME,
428
+ IN_TMUX_STATUS_NAME,
429
+ DONE_STATUS_NAME,
430
+ ICEBOX_STATUS_NAME,
431
+ ]);
432
+
433
+ expect(payload.find((s) => s.name === TODO_STATUS_NAME)?.id).toBe('id-6');
434
+ expect(payload.find((s) => s.name === IN_TMUX_STATUS_NAME)?.id).toBe(
435
+ 'id-8',
436
+ );
437
+ expect(payload.some((s) => s.name === PC_TODO_STATUS_NAME)).toBe(false);
438
+ expect(payload.some((s) => s.name === LEGACY_TODO_STATUS_NAME)).toBe(false);
439
+ expect(payload.some((s) => s.name === LEGACY_IN_TMUX_STATUS_NAME)).toBe(
440
+ false,
441
+ );
442
+ });
275
443
  });