github-issue-tower-defence-management 2.5.1 → 2.6.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 (44) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  4. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +2 -1
  5. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -1
  6. package/bin/adapter/repositories/BrowserGitHubProjectRepository.js +94 -0
  7. package/bin/adapter/repositories/BrowserGitHubProjectRepository.js.map +1 -0
  8. package/bin/adapter/repositories/GitHubIssueCheckpointRepository.js +37 -0
  9. package/bin/adapter/repositories/GitHubIssueCheckpointRepository.js.map +1 -0
  10. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +9 -1
  11. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
  12. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +10 -2
  13. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -1
  14. package/bin/domain/usecases/adapter-interfaces/IssueCheckpointRepository.js +3 -0
  15. package/bin/domain/usecases/adapter-interfaces/IssueCheckpointRepository.js.map +1 -0
  16. package/bin/domain/usecases/adapter-interfaces/StatusDefaultRepository.js +3 -0
  17. package/bin/domain/usecases/adapter-interfaces/StatusDefaultRepository.js.map +1 -0
  18. package/package.json +2 -1
  19. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -0
  20. package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +2 -0
  21. package/src/adapter/repositories/BrowserGitHubProjectRepository.test.ts +108 -0
  22. package/src/adapter/repositories/BrowserGitHubProjectRepository.ts +118 -0
  23. package/src/adapter/repositories/GitHubIssueCheckpointRepository.test.ts +51 -0
  24. package/src/adapter/repositories/GitHubIssueCheckpointRepository.ts +47 -0
  25. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +143 -0
  26. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +21 -0
  27. package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +87 -3
  28. package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +14 -1
  29. package/src/domain/usecases/adapter-interfaces/IssueCheckpointRepository.ts +3 -0
  30. package/src/domain/usecases/adapter-interfaces/StatusDefaultRepository.ts +5 -0
  31. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  32. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -1
  33. package/types/adapter/repositories/BrowserGitHubProjectRepository.d.ts +10 -0
  34. package/types/adapter/repositories/BrowserGitHubProjectRepository.d.ts.map +1 -0
  35. package/types/adapter/repositories/GitHubIssueCheckpointRepository.d.ts +8 -0
  36. package/types/adapter/repositories/GitHubIssueCheckpointRepository.d.ts.map +1 -0
  37. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts +3 -1
  38. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
  39. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +3 -1
  40. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -1
  41. package/types/domain/usecases/adapter-interfaces/IssueCheckpointRepository.d.ts +4 -0
  42. package/types/domain/usecases/adapter-interfaces/IssueCheckpointRepository.d.ts.map +1 -0
  43. package/types/domain/usecases/adapter-interfaces/StatusDefaultRepository.d.ts +5 -0
  44. package/types/domain/usecases/adapter-interfaces/StatusDefaultRepository.d.ts.map +1 -0
@@ -0,0 +1,108 @@
1
+ import { BrowserGitHubProjectRepository } from './BrowserGitHubProjectRepository';
2
+ import { Project } from '../../domain/entities/Project';
3
+
4
+ jest.mock('playwright', () => ({
5
+ chromium: {
6
+ launch: jest.fn(),
7
+ },
8
+ }));
9
+
10
+ const buildProject = (url: string): Project => ({
11
+ id: 'project-1',
12
+ url,
13
+ databaseId: 1,
14
+ name: 'test-project',
15
+ status: {
16
+ name: 'Status',
17
+ fieldId: 'status-field-1',
18
+ statuses: [
19
+ {
20
+ id: 'opt-aw',
21
+ name: 'Awaiting Workspace',
22
+ color: 'BLUE',
23
+ description: '',
24
+ },
25
+ ],
26
+ },
27
+ nextActionDate: null,
28
+ nextActionHour: null,
29
+ story: null,
30
+ remainingEstimationMinutes: null,
31
+ dependedIssueUrlSeparatedByComma: null,
32
+ completionDate50PercentConfidence: null,
33
+ agent: null,
34
+ });
35
+
36
+ describe('BrowserGitHubProjectRepository', () => {
37
+ it('should call console.warn and return without throwing when username is undefined', async () => {
38
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
39
+ const repo = new BrowserGitHubProjectRepository(
40
+ undefined,
41
+ 'password',
42
+ undefined,
43
+ );
44
+ const project = buildProject(
45
+ 'https://github.com/users/testuser/projects/1',
46
+ );
47
+
48
+ await expect(
49
+ repo.setStatusFieldDefault(project, 'opt-aw'),
50
+ ).resolves.toBeUndefined();
51
+
52
+ expect(warnSpy).toHaveBeenCalledWith(
53
+ expect.stringContaining('GITHUB_USERNAME or GITHUB_PASSWORD is unset'),
54
+ );
55
+ warnSpy.mockRestore();
56
+ });
57
+
58
+ it('should call console.warn and return without throwing when password is undefined', async () => {
59
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
60
+ const repo = new BrowserGitHubProjectRepository(
61
+ 'username',
62
+ undefined,
63
+ undefined,
64
+ );
65
+ const project = buildProject(
66
+ 'https://github.com/users/testuser/projects/1',
67
+ );
68
+
69
+ await expect(
70
+ repo.setStatusFieldDefault(project, 'opt-aw'),
71
+ ).resolves.toBeUndefined();
72
+
73
+ expect(warnSpy).toHaveBeenCalledWith(
74
+ expect.stringContaining('GITHUB_USERNAME or GITHUB_PASSWORD is unset'),
75
+ );
76
+ warnSpy.mockRestore();
77
+ });
78
+
79
+ it('should throw when projectLocationFromUrl returns null for an invalid URL', async () => {
80
+ const repo = new BrowserGitHubProjectRepository(
81
+ 'username',
82
+ 'password',
83
+ undefined,
84
+ );
85
+ const project = buildProject('https://example.com/invalid/url');
86
+
87
+ await expect(repo.setStatusFieldDefault(project, 'opt-aw')).rejects.toThrow(
88
+ 'cannot parse project URL',
89
+ );
90
+ });
91
+
92
+ it('should throw when optionId is not found in project status list', async () => {
93
+ const repo = new BrowserGitHubProjectRepository(
94
+ 'username',
95
+ 'password',
96
+ undefined,
97
+ );
98
+ const project = buildProject(
99
+ 'https://github.com/users/testuser/projects/1',
100
+ );
101
+
102
+ await expect(
103
+ repo.setStatusFieldDefault(project, 'nonexistent-id'),
104
+ ).rejects.toThrow(
105
+ 'option with id "nonexistent-id" not found in project status list',
106
+ );
107
+ });
108
+ });
@@ -0,0 +1,118 @@
1
+ import { createHmac } from 'crypto';
2
+ import { chromium } from 'playwright';
3
+ import { Project } from '../../domain/entities/Project';
4
+ import { StatusDefaultRepository } from '../../domain/usecases/adapter-interfaces/StatusDefaultRepository';
5
+ import { projectLocationFromUrl } from './RestProjectRepository';
6
+
7
+ const generateTotp = (secret: string): string => {
8
+ const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
9
+ const secretUpper = secret.toUpperCase().replace(/=+$/, '');
10
+ let bytes = 0n;
11
+ let bitsLeft = 0;
12
+ const output: number[] = [];
13
+ for (const char of secretUpper) {
14
+ const val = base32Chars.indexOf(char);
15
+ if (val === -1) continue;
16
+ bytes = (bytes << 5n) | BigInt(val);
17
+ bitsLeft += 5;
18
+ if (bitsLeft >= 8) {
19
+ bitsLeft -= 8;
20
+ output.push(Number((bytes >> BigInt(bitsLeft)) & 0xffn));
21
+ bytes &= (1n << BigInt(bitsLeft)) - 1n;
22
+ }
23
+ }
24
+ const key = Buffer.from(output);
25
+ const counter = BigInt(Math.floor(Date.now() / 1000 / 30));
26
+ const counterBuffer = Buffer.alloc(8);
27
+ counterBuffer.writeBigUInt64BE(counter);
28
+ const hmac = createHmac('sha1', key).update(counterBuffer).digest();
29
+ const offset = hmac[hmac.length - 1] & 0x0f;
30
+ const code =
31
+ ((hmac[offset] & 0x7f) << 24) |
32
+ ((hmac[offset + 1] & 0xff) << 16) |
33
+ ((hmac[offset + 2] & 0xff) << 8) |
34
+ (hmac[offset + 3] & 0xff);
35
+ return String(code % 1_000_000).padStart(6, '0');
36
+ };
37
+
38
+ export class BrowserGitHubProjectRepository implements Pick<
39
+ StatusDefaultRepository,
40
+ 'setStatusFieldDefault'
41
+ > {
42
+ constructor(
43
+ private readonly username: string | undefined,
44
+ private readonly password: string | undefined,
45
+ private readonly totpSecret: string | undefined,
46
+ ) {}
47
+
48
+ setStatusFieldDefault = async (
49
+ project: Project,
50
+ optionId: string,
51
+ ): Promise<void> => {
52
+ const location = projectLocationFromUrl(project.url);
53
+ if (!location) {
54
+ throw new Error(
55
+ `BrowserGitHubProjectRepository: cannot parse project URL: ${project.url}`,
56
+ );
57
+ }
58
+
59
+ const settingsUrl = `https://github.com/${location.ownerType}/${location.owner}/projects/${location.projectNumber}/settings/fields/Status`;
60
+
61
+ if (!this.username || !this.password) {
62
+ console.warn(
63
+ `BrowserGitHubProjectRepository: GITHUB_USERNAME or GITHUB_PASSWORD is unset; skipping setStatusFieldDefault. Settings URL: ${settingsUrl}`,
64
+ );
65
+ return;
66
+ }
67
+
68
+ const optionName = project.status.statuses.find(
69
+ (s) => s.id === optionId,
70
+ )?.name;
71
+ if (!optionName) {
72
+ throw new Error(
73
+ `BrowserGitHubProjectRepository: option with id "${optionId}" not found in project status list`,
74
+ );
75
+ }
76
+
77
+ const browser = await chromium.launch({ headless: true });
78
+ try {
79
+ const page = await browser.newPage();
80
+
81
+ await page.goto('https://github.com/login');
82
+ await page.fill('#login_field', this.username);
83
+ await page.fill('#password', this.password);
84
+ await page.click('[name="commit"]');
85
+
86
+ const otpInput = page.locator('#app_totp');
87
+ const isOtpVisible = await otpInput.isVisible().catch(() => false);
88
+ if (isOtpVisible && this.totpSecret) {
89
+ const totp = generateTotp(this.totpSecret);
90
+ await otpInput.fill(totp);
91
+ await page.click('[type="submit"]');
92
+ }
93
+
94
+ await page.goto(settingsUrl);
95
+
96
+ const defaultSelect = page
97
+ .locator('select[aria-label="Default value"]')
98
+ .first();
99
+ await defaultSelect.waitFor({ state: 'visible' });
100
+ const options = await defaultSelect.locator('option').allTextContents();
101
+ const matchingOption = options.find((o) => o.trim() === optionName);
102
+ if (!matchingOption) {
103
+ throw new Error(
104
+ `BrowserGitHubProjectRepository: option "${optionName}" not found in Default dropdown. Available: ${options.join(', ')}`,
105
+ );
106
+ }
107
+ await defaultSelect.selectOption({ label: optionName });
108
+
109
+ const saveButton = page
110
+ .locator('button[type="submit"]')
111
+ .filter({ hasText: /save/i })
112
+ .first();
113
+ await saveButton.click();
114
+ } finally {
115
+ await browser.close();
116
+ }
117
+ };
118
+ }
@@ -0,0 +1,51 @@
1
+ import { GitHubIssueCheckpointRepository } from './GitHubIssueCheckpointRepository';
2
+
3
+ const VALID_URL = 'https://github.com/owner/repo/issues/42';
4
+ const EXPECTED_API_URL =
5
+ 'https://api.github.com/repos/owner/repo/issues/42/comments';
6
+ const EXPECTED_BODY = JSON.stringify({
7
+ body: 'From: :robot: preparation-daemon (-)\n\n```json\n{"pullRequestRequired": false}\n```\n\nThis implementation session was interrupted by the preparation daemon due to token near exhaustion. The task will be re-dispatched.',
8
+ });
9
+
10
+ describe('GitHubIssueCheckpointRepository', () => {
11
+ let repository: GitHubIssueCheckpointRepository;
12
+
13
+ beforeEach(() => {
14
+ jest.restoreAllMocks();
15
+ repository = new GitHubIssueCheckpointRepository('test-token');
16
+ });
17
+
18
+ it('posts to the correct GitHub API URL with correct headers and body', async () => {
19
+ const fetchSpy = jest
20
+ .spyOn(global, 'fetch')
21
+ .mockResolvedValue(new Response('{}', { status: 201 }));
22
+
23
+ await repository.postCheckpoint(VALID_URL);
24
+
25
+ expect(fetchSpy).toHaveBeenCalledWith(EXPECTED_API_URL, {
26
+ method: 'POST',
27
+ headers: {
28
+ Authorization: 'Bearer test-token',
29
+ Accept: 'application/vnd.github+json',
30
+ 'Content-Type': 'application/json',
31
+ },
32
+ body: EXPECTED_BODY,
33
+ });
34
+ });
35
+
36
+ it('throws when the response is not 2xx', async () => {
37
+ jest
38
+ .spyOn(global, 'fetch')
39
+ .mockResolvedValue(
40
+ new Response('Forbidden', { status: 403, statusText: 'Forbidden' }),
41
+ );
42
+
43
+ await expect(repository.postCheckpoint(VALID_URL)).rejects.toThrow('403');
44
+ });
45
+
46
+ it('throws when the URL does not match the expected GitHub issue pattern', async () => {
47
+ await expect(
48
+ repository.postCheckpoint('https://example.com/not-an-issue'),
49
+ ).rejects.toThrow('Invalid GitHub issue URL');
50
+ });
51
+ });
@@ -0,0 +1,47 @@
1
+ import { IssueCheckpointRepository } from '../../domain/usecases/adapter-interfaces/IssueCheckpointRepository';
2
+
3
+ const CHECKPOINT_COMMENT_BODY =
4
+ 'From: :robot: preparation-daemon (-)\n\n```json\n{"pullRequestRequired": false}\n```\n\nThis implementation session was interrupted by the preparation daemon due to token near exhaustion. The task will be re-dispatched.';
5
+
6
+ export class GitHubIssueCheckpointRepository implements IssueCheckpointRepository {
7
+ constructor(private readonly ghToken: string) {}
8
+
9
+ postCheckpoint = async (issueUrl: string): Promise<void> => {
10
+ const { owner, repo, issueNumber } = this.parseIssueUrl(issueUrl);
11
+
12
+ const response = await fetch(
13
+ `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
14
+ {
15
+ method: 'POST',
16
+ headers: {
17
+ Authorization: `Bearer ${this.ghToken}`,
18
+ Accept: 'application/vnd.github+json',
19
+ 'Content-Type': 'application/json',
20
+ },
21
+ body: JSON.stringify({ body: CHECKPOINT_COMMENT_BODY }),
22
+ },
23
+ );
24
+
25
+ if (!response.ok) {
26
+ throw new Error(
27
+ `Failed to post checkpoint comment to ${issueUrl}: ${response.status} ${response.statusText}`,
28
+ );
29
+ }
30
+ };
31
+
32
+ private parseIssueUrl = (
33
+ issueUrl: string,
34
+ ): { owner: string; repo: string; issueNumber: number } => {
35
+ const match = issueUrl.match(
36
+ /github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/,
37
+ );
38
+ if (!match) {
39
+ throw new Error(`Invalid GitHub issue URL: ${issueUrl}`);
40
+ }
41
+ return {
42
+ owner: match[1],
43
+ repo: match[2],
44
+ issueNumber: parseInt(match[4], 10),
45
+ };
46
+ };
47
+ }
@@ -2,6 +2,7 @@ import { mock } from 'jest-mock-extended';
2
2
  import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
3
3
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
4
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
5
+ import { StatusDefaultRepository } from './adapter-interfaces/StatusDefaultRepository';
5
6
  import { FieldOption, Project } from '../entities/Project';
6
7
  import { Issue } from '../entities/Issue';
7
8
  import {
@@ -111,9 +112,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
111
112
  cacheUsed: false,
112
113
  });
113
114
 
115
+ const mockStatusDefaultRepository =
116
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
114
117
  const useCase = new SetupTowerDefenceProjectUseCase(
115
118
  mockProjectRepository,
116
119
  mockIssueRepository,
120
+ mockStatusDefaultRepository,
117
121
  );
118
122
  await useCase.run({ projectUrl: project.url });
119
123
 
@@ -142,9 +146,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
142
146
  cacheUsed: false,
143
147
  });
144
148
 
149
+ const mockStatusDefaultRepository =
150
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
145
151
  const useCase = new SetupTowerDefenceProjectUseCase(
146
152
  mockProjectRepository,
147
153
  mockIssueRepository,
154
+ mockStatusDefaultRepository,
148
155
  );
149
156
  await useCase.run({ projectUrl: project.url });
150
157
 
@@ -167,9 +174,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
167
174
  cacheUsed: false,
168
175
  });
169
176
 
177
+ const mockStatusDefaultRepository =
178
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
170
179
  const useCase = new SetupTowerDefenceProjectUseCase(
171
180
  mockProjectRepository,
172
181
  mockIssueRepository,
182
+ mockStatusDefaultRepository,
173
183
  );
174
184
  await useCase.run({ projectUrl: project.url });
175
185
 
@@ -210,9 +220,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
210
220
  cacheUsed: false,
211
221
  });
212
222
 
223
+ const mockStatusDefaultRepository =
224
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
213
225
  const useCase = new SetupTowerDefenceProjectUseCase(
214
226
  mockProjectRepository,
215
227
  mockIssueRepository,
228
+ mockStatusDefaultRepository,
216
229
  );
217
230
  await useCase.run({ projectUrl: project.url });
218
231
 
@@ -312,9 +325,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
312
325
  cacheUsed: false,
313
326
  });
314
327
 
328
+ const mockStatusDefaultRepository =
329
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
315
330
  const useCase = new SetupTowerDefenceProjectUseCase(
316
331
  mockProjectRepository,
317
332
  mockIssueRepository,
333
+ mockStatusDefaultRepository,
318
334
  );
319
335
  await useCase.run({ projectUrl: project.url });
320
336
 
@@ -350,9 +366,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
350
366
  cacheUsed: false,
351
367
  });
352
368
 
369
+ const mockStatusDefaultRepository =
370
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
353
371
  const useCase = new SetupTowerDefenceProjectUseCase(
354
372
  mockProjectRepository,
355
373
  mockIssueRepository,
374
+ mockStatusDefaultRepository,
356
375
  );
357
376
  await useCase.run({ projectUrl: project.url });
358
377
 
@@ -395,9 +414,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
395
414
  cacheUsed: false,
396
415
  });
397
416
 
417
+ const mockStatusDefaultRepository =
418
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
398
419
  const useCase = new SetupTowerDefenceProjectUseCase(
399
420
  mockProjectRepository,
400
421
  mockIssueRepository,
422
+ mockStatusDefaultRepository,
401
423
  );
402
424
  await useCase.run({ projectUrl: project.url });
403
425
 
@@ -437,9 +459,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
437
459
  cacheUsed: false,
438
460
  });
439
461
 
462
+ const mockStatusDefaultRepository =
463
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
440
464
  const useCase = new SetupTowerDefenceProjectUseCase(
441
465
  mockProjectRepository,
442
466
  mockIssueRepository,
467
+ mockStatusDefaultRepository,
443
468
  );
444
469
  await useCase.run({ projectUrl: project.url });
445
470
 
@@ -470,9 +495,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
470
495
  cacheUsed: false,
471
496
  });
472
497
 
498
+ const mockStatusDefaultRepository =
499
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
473
500
  const useCase = new SetupTowerDefenceProjectUseCase(
474
501
  mockProjectRepository,
475
502
  mockIssueRepository,
503
+ mockStatusDefaultRepository,
476
504
  );
477
505
  await useCase.run({ projectUrl: project.url });
478
506
 
@@ -513,9 +541,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
513
541
  cacheUsed: false,
514
542
  });
515
543
 
544
+ const mockStatusDefaultRepository =
545
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
516
546
  const useCase = new SetupTowerDefenceProjectUseCase(
517
547
  mockProjectRepository,
518
548
  mockIssueRepository,
549
+ mockStatusDefaultRepository,
519
550
  );
520
551
  await useCase.run({ projectUrl: project.url });
521
552
 
@@ -548,9 +579,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
548
579
  cacheUsed: false,
549
580
  });
550
581
 
582
+ const mockStatusDefaultRepository =
583
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
551
584
  const useCase = new SetupTowerDefenceProjectUseCase(
552
585
  mockProjectRepository,
553
586
  mockIssueRepository,
587
+ mockStatusDefaultRepository,
554
588
  );
555
589
  await useCase.run({ projectUrl: project.url });
556
590
 
@@ -591,9 +625,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
591
625
  cacheUsed: false,
592
626
  });
593
627
 
628
+ const mockStatusDefaultRepository =
629
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
594
630
  const useCase = new SetupTowerDefenceProjectUseCase(
595
631
  mockProjectRepository,
596
632
  mockIssueRepository,
633
+ mockStatusDefaultRepository,
597
634
  );
598
635
  await useCase.run({ projectUrl: project.url });
599
636
 
@@ -632,9 +669,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
632
669
  cacheUsed: false,
633
670
  });
634
671
 
672
+ const mockStatusDefaultRepository =
673
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
635
674
  const useCase = new SetupTowerDefenceProjectUseCase(
636
675
  mockProjectRepository,
637
676
  mockIssueRepository,
677
+ mockStatusDefaultRepository,
638
678
  );
639
679
  await useCase.run({ projectUrl: project.url });
640
680
 
@@ -698,9 +738,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
698
738
  cacheUsed: false,
699
739
  });
700
740
 
741
+ const mockStatusDefaultRepository =
742
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
701
743
  const useCase = new SetupTowerDefenceProjectUseCase(
702
744
  mockProjectRepository,
703
745
  mockIssueRepository,
746
+ mockStatusDefaultRepository,
704
747
  );
705
748
  await useCase.run({ projectUrl: project.url });
706
749
 
@@ -812,9 +855,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
812
855
  });
813
856
  mockIssueRepository.updateStatus.mockResolvedValue(undefined);
814
857
 
858
+ const mockStatusDefaultRepository =
859
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
815
860
  const useCase = new SetupTowerDefenceProjectUseCase(
816
861
  mockProjectRepository,
817
862
  mockIssueRepository,
863
+ mockStatusDefaultRepository,
818
864
  );
819
865
  await useCase.run({ projectUrl: project.url });
820
866
 
@@ -865,9 +911,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
865
911
  cacheUsed: false,
866
912
  });
867
913
 
914
+ const mockStatusDefaultRepository =
915
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
868
916
  const useCase = new SetupTowerDefenceProjectUseCase(
869
917
  mockProjectRepository,
870
918
  mockIssueRepository,
919
+ mockStatusDefaultRepository,
871
920
  );
872
921
  await useCase.run({ projectUrl: project.url });
873
922
 
@@ -899,9 +948,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
899
948
  });
900
949
  mockIssueRepository.updateStatus.mockResolvedValue(undefined);
901
950
 
951
+ const mockStatusDefaultRepository =
952
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
902
953
  const useCase = new SetupTowerDefenceProjectUseCase(
903
954
  mockProjectRepository,
904
955
  mockIssueRepository,
956
+ mockStatusDefaultRepository,
905
957
  );
906
958
  await useCase.run({ projectUrl: project.url });
907
959
 
@@ -976,9 +1028,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
976
1028
  });
977
1029
  mockIssueRepository.updateStatus.mockResolvedValue(undefined);
978
1030
 
1031
+ const mockStatusDefaultRepository =
1032
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
979
1033
  const useCase = new SetupTowerDefenceProjectUseCase(
980
1034
  mockProjectRepository,
981
1035
  mockIssueRepository,
1036
+ mockStatusDefaultRepository,
982
1037
  );
983
1038
  await useCase.run({ projectUrl: project.url });
984
1039
 
@@ -1037,9 +1092,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
1037
1092
  });
1038
1093
  mockIssueRepository.updateStatus.mockResolvedValue(undefined);
1039
1094
 
1095
+ const mockStatusDefaultRepository =
1096
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1040
1097
  const useCase = new SetupTowerDefenceProjectUseCase(
1041
1098
  mockProjectRepository,
1042
1099
  mockIssueRepository,
1100
+ mockStatusDefaultRepository,
1043
1101
  );
1044
1102
  await useCase.run({ projectUrl: project.url });
1045
1103
 
@@ -1075,9 +1133,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
1075
1133
  });
1076
1134
  mockIssueRepository.updateStatus.mockResolvedValue(undefined);
1077
1135
 
1136
+ const mockStatusDefaultRepository =
1137
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1078
1138
  const useCase = new SetupTowerDefenceProjectUseCase(
1079
1139
  mockProjectRepository,
1080
1140
  mockIssueRepository,
1141
+ mockStatusDefaultRepository,
1081
1142
  );
1082
1143
  await useCase.run({ projectUrl: project.url });
1083
1144
 
@@ -1111,9 +1172,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
1111
1172
  cacheUsed: false,
1112
1173
  });
1113
1174
 
1175
+ const mockStatusDefaultRepository =
1176
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1114
1177
  const useCase = new SetupTowerDefenceProjectUseCase(
1115
1178
  mockProjectRepository,
1116
1179
  mockIssueRepository,
1180
+ mockStatusDefaultRepository,
1117
1181
  );
1118
1182
  await useCase.run({ projectUrl: project.url });
1119
1183
 
@@ -1142,9 +1206,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
1142
1206
  cacheUsed: false,
1143
1207
  });
1144
1208
 
1209
+ const mockStatusDefaultRepository =
1210
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1145
1211
  const useCase = new SetupTowerDefenceProjectUseCase(
1146
1212
  mockProjectRepository,
1147
1213
  mockIssueRepository,
1214
+ mockStatusDefaultRepository,
1148
1215
  );
1149
1216
  await useCase.run({ projectUrl: project.url });
1150
1217
 
@@ -1199,9 +1266,12 @@ describe('SetupTowerDefenceProjectUseCase', () => {
1199
1266
  });
1200
1267
  mockIssueRepository.updateStatus.mockResolvedValue(undefined);
1201
1268
 
1269
+ const mockStatusDefaultRepository =
1270
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1202
1271
  const useCase = new SetupTowerDefenceProjectUseCase(
1203
1272
  mockProjectRepository,
1204
1273
  mockIssueRepository,
1274
+ mockStatusDefaultRepository,
1205
1275
  );
1206
1276
  await useCase.run({ projectUrl: project.url });
1207
1277
 
@@ -1217,4 +1287,77 @@ describe('SetupTowerDefenceProjectUseCase', () => {
1217
1287
  awaitingWorkspaceId,
1218
1288
  );
1219
1289
  });
1290
+
1291
+ it('should call setStatusFieldDefault with Awaiting Workspace option id when statuses are already in canonical order', async () => {
1292
+ const mockProjectRepository =
1293
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
1294
+ const mockIssueRepository =
1295
+ mock<Pick<IssueRepository, 'getAllIssues' | 'updateStatus'>>();
1296
+ const canonicalStatuses = buildCanonicalStatuses();
1297
+ const project = buildProject(canonicalStatuses);
1298
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
1299
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1300
+ project: mock<Project>(),
1301
+ issues: [],
1302
+ cacheUsed: false,
1303
+ });
1304
+
1305
+ const mockStatusDefaultRepository =
1306
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1307
+ const useCase = new SetupTowerDefenceProjectUseCase(
1308
+ mockProjectRepository,
1309
+ mockIssueRepository,
1310
+ mockStatusDefaultRepository,
1311
+ );
1312
+ await useCase.run({ projectUrl: project.url });
1313
+
1314
+ expect(mockProjectRepository.updateStatusList).not.toHaveBeenCalled();
1315
+ expect(
1316
+ mockStatusDefaultRepository.setStatusFieldDefault,
1317
+ ).toHaveBeenCalledTimes(1);
1318
+ expect(
1319
+ mockStatusDefaultRepository.setStatusFieldDefault,
1320
+ ).toHaveBeenCalledWith(project, canonicalStatuses[0].id);
1321
+ });
1322
+
1323
+ it('should call setStatusFieldDefault with Awaiting Workspace option id after updateStatusList', async () => {
1324
+ const mockProjectRepository =
1325
+ mock<Pick<ProjectRepository, 'getByUrl' | 'updateStatusList'>>();
1326
+ const mockIssueRepository =
1327
+ mock<Pick<IssueRepository, 'getAllIssues' | 'updateStatus'>>();
1328
+ const awaitingWorkspaceId = 'existing-aws-id';
1329
+ const statuses: FieldOption[] = [
1330
+ {
1331
+ id: awaitingWorkspaceId,
1332
+ name: AWAITING_WORKSPACE_STATUS_NAME,
1333
+ color: 'BLUE',
1334
+ description: 'stale description',
1335
+ },
1336
+ ];
1337
+ const project = buildProject(statuses);
1338
+ mockProjectRepository.getByUrl.mockResolvedValue(project);
1339
+ mockProjectRepository.updateStatusList.mockResolvedValue([]);
1340
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1341
+ project: mock<Project>(),
1342
+ issues: [],
1343
+ cacheUsed: false,
1344
+ });
1345
+
1346
+ const mockStatusDefaultRepository =
1347
+ mock<Pick<StatusDefaultRepository, 'setStatusFieldDefault'>>();
1348
+ const useCase = new SetupTowerDefenceProjectUseCase(
1349
+ mockProjectRepository,
1350
+ mockIssueRepository,
1351
+ mockStatusDefaultRepository,
1352
+ );
1353
+ await useCase.run({ projectUrl: project.url });
1354
+
1355
+ expect(mockProjectRepository.updateStatusList).toHaveBeenCalledTimes(1);
1356
+ expect(
1357
+ mockStatusDefaultRepository.setStatusFieldDefault,
1358
+ ).toHaveBeenCalledTimes(1);
1359
+ expect(
1360
+ mockStatusDefaultRepository.setStatusFieldDefault,
1361
+ ).toHaveBeenCalledWith(project, awaitingWorkspaceId);
1362
+ });
1220
1363
  });