github-issue-tower-defence-management 1.32.0 → 1.34.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 (49) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +92 -6
  3. package/bin/adapter/entry-points/cli/index.js +422 -5
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +67 -33
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/repositories/FetchWebhookRepository.js +10 -0
  8. package/bin/adapter/repositories/FetchWebhookRepository.js.map +1 -0
  9. package/bin/adapter/repositories/GitHubIssueCommentRepository.js +190 -0
  10. package/bin/adapter/repositories/GitHubIssueCommentRepository.js.map +1 -0
  11. package/bin/adapter/repositories/OauthAPIClaudeRepository.js +225 -0
  12. package/bin/adapter/repositories/OauthAPIClaudeRepository.js.map +1 -0
  13. package/bin/domain/usecases/HandleScheduledEventUseCase.js +17 -1
  14. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  15. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +73 -17
  16. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  17. package/bin/domain/usecases/adapter-interfaces/WebhookRepository.js +3 -0
  18. package/bin/domain/usecases/adapter-interfaces/WebhookRepository.js.map +1 -0
  19. package/package.json +1 -1
  20. package/src/adapter/entry-points/cli/index.test.ts +1315 -15
  21. package/src/adapter/entry-points/cli/index.ts +648 -5
  22. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +14 -0
  23. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +17 -2
  24. package/src/adapter/repositories/FetchWebhookRepository.ts +7 -0
  25. package/src/adapter/repositories/GitHubIssueCommentRepository.ts +291 -0
  26. package/src/adapter/repositories/OauthAPIClaudeRepository.ts +279 -0
  27. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +28 -0
  28. package/src/domain/usecases/HandleScheduledEventUseCase.ts +30 -0
  29. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +722 -16
  30. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +117 -20
  31. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +2 -0
  32. package/src/domain/usecases/adapter-interfaces/WebhookRepository.ts +3 -0
  33. package/types/adapter/entry-points/cli/index.d.ts +19 -0
  34. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  35. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  36. package/types/adapter/repositories/FetchWebhookRepository.d.ts +5 -0
  37. package/types/adapter/repositories/FetchWebhookRepository.d.ts.map +1 -0
  38. package/types/adapter/repositories/GitHubIssueCommentRepository.d.ts +12 -0
  39. package/types/adapter/repositories/GitHubIssueCommentRepository.d.ts.map +1 -0
  40. package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts +13 -0
  41. package/types/adapter/repositories/OauthAPIClaudeRepository.d.ts.map +1 -0
  42. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +10 -1
  43. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  44. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +5 -1
  45. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  46. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +2 -0
  47. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  48. package/types/domain/usecases/adapter-interfaces/WebhookRepository.d.ts +4 -0
  49. package/types/domain/usecases/adapter-interfaces/WebhookRepository.d.ts.map +1 -0
@@ -1,21 +1,1321 @@
1
- import { execSync } from 'child_process';
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import YAML from 'yaml';
4
+ import {
5
+ program,
6
+ loadConfigFile,
7
+ parseProjectReadmeConfig,
8
+ mergeConfigs,
9
+ fetchProjectReadme,
10
+ } from './index';
11
+ import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
12
+ import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
2
13
 
3
- describe('commander program', () => {
4
- it('should output help contents', () => {
5
- const output = execSync(
6
- 'npx ts-node ./src/adapter/entry-points/cli/index.ts -h',
7
- ).toString();
14
+ jest.mock('../../../domain/usecases/StartPreparationUseCase');
15
+ jest.mock('../../../domain/usecases/NotifyFinishedIssuePreparationUseCase');
16
+ jest.mock('../../repositories/LocalStorageRepository', () => ({
17
+ LocalStorageRepository: jest.fn().mockImplementation(() => ({})),
18
+ }));
19
+ jest.mock('../../repositories/LocalStorageCacheRepository', () => ({
20
+ LocalStorageCacheRepository: jest.fn().mockImplementation(() => ({})),
21
+ }));
22
+ jest.mock('../../repositories/GraphqlProjectRepository', () => ({
23
+ GraphqlProjectRepository: jest.fn().mockImplementation(() => ({})),
24
+ }));
25
+ jest.mock('../../repositories/CheerioProjectRepository', () => ({
26
+ CheerioProjectRepository: jest.fn().mockImplementation(() => ({})),
27
+ }));
28
+ jest.mock('../../repositories/issue/ApiV3IssueRepository', () => ({
29
+ ApiV3IssueRepository: jest.fn().mockImplementation(() => ({})),
30
+ }));
31
+ jest.mock('../../repositories/issue/RestIssueRepository', () => ({
32
+ RestIssueRepository: jest.fn().mockImplementation(() => ({})),
33
+ }));
34
+ jest.mock('../../repositories/issue/GraphqlProjectItemRepository', () => ({
35
+ GraphqlProjectItemRepository: jest.fn().mockImplementation(() => ({})),
36
+ }));
37
+ jest.mock('../../repositories/issue/ApiV3CheerioRestIssueRepository', () => ({
38
+ ApiV3CheerioRestIssueRepository: jest.fn().mockImplementation(() => ({})),
39
+ }));
40
+ jest.mock('../../repositories/NodeLocalCommandRunner', () => ({
41
+ NodeLocalCommandRunner: jest.fn().mockImplementation(() => ({})),
42
+ }));
43
+ jest.mock('../../repositories/OauthAPIClaudeRepository', () => ({
44
+ OauthAPIClaudeRepository: jest.fn().mockImplementation(() => ({
45
+ getUsage: jest.fn(),
46
+ isClaudeAvailable: jest.fn(),
47
+ })),
48
+ }));
49
+ jest.mock('../../repositories/GitHubIssueCommentRepository', () => ({
50
+ GitHubIssueCommentRepository: jest.fn().mockImplementation(() => ({})),
51
+ }));
52
+ jest.mock('../../repositories/FetchWebhookRepository', () => ({
53
+ FetchWebhookRepository: jest.fn().mockImplementation(() => ({
54
+ sendGetRequest: jest.fn(),
55
+ })),
56
+ }));
57
+ jest.mock('../handlers/HandleScheduledEventUseCaseHandler', () => ({
58
+ HandleScheduledEventUseCaseHandler: jest.fn().mockImplementation(() => ({
59
+ handle: jest.fn().mockResolvedValue(null),
60
+ })),
61
+ }));
8
62
 
9
- expect(output.trim())
10
- .toEqual(`Usage: github-issue-tower-defence-management [options]
63
+ describe('CLI', () => {
64
+ const originalEnv = process.env;
65
+ const tmpDir = path.join(__dirname, '../../../../tmp/test-cli');
66
+ const configFilePath = path.join(tmpDir, 'config.yml');
11
67
 
12
- CLI tool for GitHub Issue Tower Defence Management
68
+ const defaultConfig = {
69
+ projectUrl: 'https://github.com/orgs/test/projects/1',
70
+ awaitingWorkspaceStatus: 'Awaiting',
71
+ preparationStatus: 'Preparing',
72
+ defaultAgentName: 'agent1',
73
+ awaitingQualityCheckStatus: 'Awaiting QC',
74
+ projectName: 'test-project',
75
+ };
13
76
 
14
- Options:
15
- -t, --trigger <type> Trigger type: issue or schedule
16
- -c, --config <path> Path to config YAML file
17
- -v, --verbose Verbose output
18
- -i, --issue <url> GitHub Issue URL
19
- -h, --help display help for command`);
77
+ const writeConfig = (config: Record<string, unknown>): void => {
78
+ fs.writeFileSync(configFilePath, YAML.stringify(config));
79
+ };
80
+
81
+ const mockFetchReturningReadme = (readme: string | null): void => {
82
+ const responseBody =
83
+ readme === null
84
+ ? { data: {} }
85
+ : { data: { organization: { projectV2: { readme } } } };
86
+ jest.spyOn(global, 'fetch').mockResolvedValue(
87
+ new Response(JSON.stringify(responseBody), {
88
+ status: 200,
89
+ headers: { 'Content-Type': 'application/json' },
90
+ }),
91
+ );
92
+ };
93
+
94
+ beforeAll(() => {
95
+ if (!fs.existsSync(tmpDir)) {
96
+ fs.mkdirSync(tmpDir, { recursive: true });
97
+ }
98
+ });
99
+
100
+ afterAll(() => {
101
+ if (fs.existsSync(configFilePath)) {
102
+ fs.unlinkSync(configFilePath);
103
+ }
104
+ if (fs.existsSync(tmpDir)) {
105
+ fs.rmdirSync(tmpDir);
106
+ }
107
+ });
108
+
109
+ beforeEach(() => {
110
+ jest.clearAllMocks();
111
+ mockFetchReturningReadme(null);
112
+ process.env = { ...originalEnv, GH_TOKEN: 'test-token' };
113
+ writeConfig(defaultConfig);
114
+ });
115
+
116
+ afterEach(() => {
117
+ process.env = originalEnv;
118
+ });
119
+
120
+ it('should export program', () => {
121
+ expect(program).toBeDefined();
122
+ });
123
+
124
+ describe('loadConfigFile', () => {
125
+ it('should load config from YAML file', () => {
126
+ const config = {
127
+ projectUrl: 'https://github.com/orgs/test/projects/1',
128
+ awaitingWorkspaceStatus: 'Awaiting',
129
+ preparationStatus: 'Preparing',
130
+ defaultAgentName: 'agent1',
131
+ logFilePath: '/path/to/log.txt',
132
+ maximumPreparingIssuesCount: 10,
133
+ allowIssueCacheMinutes: 5,
134
+ awaitingQualityCheckStatus: 'Awaiting QC',
135
+ thresholdForAutoReject: 5,
136
+ workflowBlockerResolvedWebhookUrl: 'https://example.com/webhook',
137
+ projectName: 'test-project',
138
+ };
139
+ writeConfig(config);
140
+
141
+ const result = loadConfigFile(configFilePath);
142
+
143
+ expect(result).toEqual(config);
144
+ });
145
+
146
+ it('should return empty config for empty YAML', () => {
147
+ fs.writeFileSync(configFilePath, '');
148
+
149
+ const result = loadConfigFile(configFilePath);
150
+
151
+ expect(result).toEqual({});
152
+ });
153
+
154
+ it('should ignore non-string values for string fields', () => {
155
+ const config = {
156
+ projectUrl: 123,
157
+ awaitingWorkspaceStatus: true,
158
+ };
159
+ writeConfig(config);
160
+
161
+ const result = loadConfigFile(configFilePath);
162
+
163
+ expect(result.projectUrl).toBeUndefined();
164
+ expect(result.awaitingWorkspaceStatus).toBeUndefined();
165
+ });
166
+
167
+ it('should ignore non-number values for number fields', () => {
168
+ const config = {
169
+ maximumPreparingIssuesCount: 'abc',
170
+ allowIssueCacheMinutes: 'def',
171
+ thresholdForAutoReject: 'ghi',
172
+ };
173
+ writeConfig(config);
174
+
175
+ const result = loadConfigFile(configFilePath);
176
+
177
+ expect(result.maximumPreparingIssuesCount).toBeUndefined();
178
+ expect(result.allowIssueCacheMinutes).toBeUndefined();
179
+ expect(result.thresholdForAutoReject).toBeUndefined();
180
+ });
181
+
182
+ it('should return empty config for array YAML', () => {
183
+ fs.writeFileSync(configFilePath, '- item1\n- item2\n');
184
+
185
+ const result = loadConfigFile(configFilePath);
186
+
187
+ expect(result).toEqual({});
188
+ });
189
+
190
+ it('should exit with error when config file does not exist', () => {
191
+ const nonExistentPath = path.join(tmpDir, 'nonexistent-config.yml');
192
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
193
+ const processExitSpy = jest
194
+ .spyOn(process, 'exit')
195
+ .mockImplementation(() => {
196
+ throw new Error('process.exit called');
197
+ });
198
+
199
+ expect(() => loadConfigFile(nonExistentPath)).toThrow(
200
+ 'process.exit called',
201
+ );
202
+
203
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
204
+ expect.stringContaining(
205
+ `Failed to load configuration file "${nonExistentPath}"`,
206
+ ),
207
+ );
208
+ expect(processExitSpy).toHaveBeenCalledWith(1);
209
+
210
+ consoleErrorSpy.mockRestore();
211
+ processExitSpy.mockRestore();
212
+ });
213
+ });
214
+
215
+ describe('parseProjectReadmeConfig', () => {
216
+ it('should parse YAML from details/summary config section', () => {
217
+ const readme = `# Project
218
+ Some description
219
+ <details>
220
+ <summary>config</summary>
221
+ awaitingWorkspaceStatus: 'Custom Awaiting'
222
+ preparationStatus: 'Custom Preparing'
223
+ defaultAgentName: 'readme-agent'
224
+ </details>`;
225
+
226
+ const result = parseProjectReadmeConfig(readme);
227
+
228
+ expect(result.awaitingWorkspaceStatus).toBe('Custom Awaiting');
229
+ expect(result.preparationStatus).toBe('Custom Preparing');
230
+ expect(result.defaultAgentName).toBe('readme-agent');
231
+ });
232
+
233
+ it('should return empty config when no details/summary section exists', () => {
234
+ const readme = '# Project\nSome description without config section';
235
+
236
+ const result = parseProjectReadmeConfig(readme);
237
+
238
+ expect(result).toEqual({});
239
+ });
240
+
241
+ it('should return empty config when details section has empty content', () => {
242
+ const readme = '<details>\n<summary>config</summary>\n</details>';
243
+
244
+ const result = parseProjectReadmeConfig(readme);
245
+
246
+ expect(result).toEqual({});
247
+ });
248
+
249
+ it('should return empty config when YAML content is not a record', () => {
250
+ const readme =
251
+ '<details>\n<summary>config</summary>\n- item1\n- item2\n</details>';
252
+
253
+ const result = parseProjectReadmeConfig(readme);
254
+
255
+ expect(result).toEqual({});
256
+ });
257
+
258
+ it('should handle invalid YAML gracefully', () => {
259
+ const readme =
260
+ '<details>\n<summary>config</summary>\ninvalid: [unclosed\n</details>';
261
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
262
+
263
+ const result = parseProjectReadmeConfig(readme);
264
+
265
+ expect(result).toEqual({});
266
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
267
+ 'Failed to parse YAML from project README config section',
268
+ );
269
+
270
+ consoleWarnSpy.mockRestore();
271
+ });
272
+
273
+ it('should parse number fields from README config', () => {
274
+ const readme = `<details>
275
+ <summary>config</summary>
276
+ maximumPreparingIssuesCount: 15
277
+ allowIssueCacheMinutes: 10
278
+ thresholdForAutoReject: 5
279
+ </details>`;
280
+
281
+ const result = parseProjectReadmeConfig(readme);
282
+
283
+ expect(result.maximumPreparingIssuesCount).toBe(15);
284
+ expect(result.allowIssueCacheMinutes).toBe(10);
285
+ expect(result.thresholdForAutoReject).toBe(5);
286
+ });
287
+
288
+ it('should be case-insensitive for the summary tag', () => {
289
+ const readme = `<details>
290
+ <SUMMARY>config</SUMMARY>
291
+ defaultAgentName: 'case-test-agent'
292
+ </details>`;
293
+
294
+ const result = parseProjectReadmeConfig(readme);
295
+
296
+ expect(result.defaultAgentName).toBe('case-test-agent');
297
+ });
298
+ });
299
+
300
+ describe('mergeConfigs', () => {
301
+ it('should use configFile values when no overrides', () => {
302
+ const configFile = {
303
+ projectUrl: 'https://github.com/config/project',
304
+ defaultAgentName: 'config-agent',
305
+ };
306
+
307
+ const result = mergeConfigs(configFile, {}, {});
308
+
309
+ expect(result.projectUrl).toBe('https://github.com/config/project');
310
+ expect(result.defaultAgentName).toBe('config-agent');
311
+ });
312
+
313
+ it('should use CLI overrides over configFile', () => {
314
+ const configFile = {
315
+ projectUrl: 'https://github.com/config/project',
316
+ defaultAgentName: 'config-agent',
317
+ };
318
+ const cliOverrides = {
319
+ defaultAgentName: 'cli-agent',
320
+ };
321
+
322
+ const result = mergeConfigs(configFile, cliOverrides, {});
323
+
324
+ expect(result.projectUrl).toBe('https://github.com/config/project');
325
+ expect(result.defaultAgentName).toBe('cli-agent');
326
+ });
327
+
328
+ it('should use README overrides over both CLI and configFile', () => {
329
+ const configFile = {
330
+ projectUrl: 'https://github.com/config/project',
331
+ defaultAgentName: 'config-agent',
332
+ };
333
+ const cliOverrides = {
334
+ defaultAgentName: 'cli-agent',
335
+ };
336
+ const readmeOverrides = {
337
+ defaultAgentName: 'readme-agent',
338
+ };
339
+
340
+ const result = mergeConfigs(configFile, cliOverrides, readmeOverrides);
341
+
342
+ expect(result.projectUrl).toBe('https://github.com/config/project');
343
+ expect(result.defaultAgentName).toBe('readme-agent');
344
+ });
345
+
346
+ it('should preserve projectName from configFile', () => {
347
+ const configFile = {
348
+ projectName: 'my-project',
349
+ projectUrl: 'https://github.com/config/project',
350
+ };
351
+
352
+ const result = mergeConfigs(configFile, {}, {});
353
+
354
+ expect(result.projectName).toBe('my-project');
355
+ });
356
+ });
357
+
358
+ describe('fetchProjectReadme', () => {
359
+ it('should fetch README from GitHub GraphQL API', async () => {
360
+ const responseBody = {
361
+ data: {
362
+ organization: {
363
+ projectV2: { readme: '# Project README' },
364
+ },
365
+ },
366
+ };
367
+ jest
368
+ .spyOn(global, 'fetch')
369
+ .mockResolvedValue(
370
+ new Response(JSON.stringify(responseBody), { status: 200 }),
371
+ );
372
+
373
+ const result = await fetchProjectReadme(
374
+ 'https://github.com/orgs/test-org/projects/1',
375
+ 'test-token',
376
+ );
377
+
378
+ expect(result).toBe('# Project README');
379
+ });
380
+
381
+ it('should try user if organization readme is null', async () => {
382
+ const responseBody = {
383
+ data: {
384
+ organization: { projectV2: { readme: null } },
385
+ user: { projectV2: { readme: '# User Project README' } },
386
+ },
387
+ };
388
+ jest
389
+ .spyOn(global, 'fetch')
390
+ .mockResolvedValue(
391
+ new Response(JSON.stringify(responseBody), { status: 200 }),
392
+ );
393
+
394
+ const result = await fetchProjectReadme(
395
+ 'https://github.com/users/test-user/projects/1',
396
+ 'test-token',
397
+ );
398
+
399
+ expect(result).toBe('# User Project README');
400
+ });
401
+
402
+ it('should return null and warn on failure', async () => {
403
+ jest.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'));
404
+
405
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
406
+
407
+ const result = await fetchProjectReadme(
408
+ 'https://github.com/orgs/test/projects/1',
409
+ 'test-token',
410
+ );
411
+
412
+ expect(result).toBeNull();
413
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
414
+ 'Failed to fetch project README',
415
+ );
416
+
417
+ consoleWarnSpy.mockRestore();
418
+ });
419
+ });
420
+
421
+ describe('startDaemon', () => {
422
+ it('should read parameters from config file', async () => {
423
+ const mockRun = jest.fn().mockResolvedValue(undefined);
424
+ const MockedStartPreparationUseCase = jest.mocked(
425
+ StartPreparationUseCase,
426
+ );
427
+
428
+ MockedStartPreparationUseCase.mockImplementation(function (
429
+ this: StartPreparationUseCase,
430
+ ) {
431
+ this.run = mockRun;
432
+ return this;
433
+ });
434
+
435
+ await program.parseAsync([
436
+ 'node',
437
+ 'test',
438
+ 'startDaemon',
439
+ '--configFilePath',
440
+ configFilePath,
441
+ ]);
442
+
443
+ expect(mockRun).toHaveBeenCalledTimes(1);
444
+ expect(mockRun).toHaveBeenCalledWith({
445
+ projectUrl: 'https://github.com/orgs/test/projects/1',
446
+ awaitingWorkspaceStatus: 'Awaiting',
447
+ preparationStatus: 'Preparing',
448
+ defaultAgentName: 'agent1',
449
+ logFilePath: undefined,
450
+ maximumPreparingIssuesCount: null,
451
+ allowIssueCacheMinutes: 0,
452
+ });
453
+ });
454
+
455
+ it('should allow CLI args to override config file values', async () => {
456
+ const mockRun = jest.fn().mockResolvedValue(undefined);
457
+ const MockedStartPreparationUseCase = jest.mocked(
458
+ StartPreparationUseCase,
459
+ );
460
+
461
+ MockedStartPreparationUseCase.mockImplementation(function (
462
+ this: StartPreparationUseCase,
463
+ ) {
464
+ this.run = mockRun;
465
+ return this;
466
+ });
467
+
468
+ await program.parseAsync([
469
+ 'node',
470
+ 'test',
471
+ 'startDaemon',
472
+ '--configFilePath',
473
+ configFilePath,
474
+ '--projectUrl',
475
+ 'https://github.com/orgs/override/projects/2',
476
+ '--defaultAgentName',
477
+ 'override-agent',
478
+ ]);
479
+
480
+ expect(mockRun).toHaveBeenCalledTimes(1);
481
+ expect(mockRun).toHaveBeenCalledWith({
482
+ projectUrl: 'https://github.com/orgs/override/projects/2',
483
+ awaitingWorkspaceStatus: 'Awaiting',
484
+ preparationStatus: 'Preparing',
485
+ defaultAgentName: 'override-agent',
486
+ logFilePath: undefined,
487
+ maximumPreparingIssuesCount: null,
488
+ allowIssueCacheMinutes: 0,
489
+ });
490
+ });
491
+
492
+ it('should pass logFilePath from config file', async () => {
493
+ const configWithLog = {
494
+ ...defaultConfig,
495
+ logFilePath: '/path/to/log.txt',
496
+ };
497
+ writeConfig(configWithLog);
498
+
499
+ const mockRun = jest.fn().mockResolvedValue(undefined);
500
+ const MockedStartPreparationUseCase = jest.mocked(
501
+ StartPreparationUseCase,
502
+ );
503
+
504
+ MockedStartPreparationUseCase.mockImplementation(function (
505
+ this: StartPreparationUseCase,
506
+ ) {
507
+ this.run = mockRun;
508
+ return this;
509
+ });
510
+
511
+ await program.parseAsync([
512
+ 'node',
513
+ 'test',
514
+ 'startDaemon',
515
+ '--configFilePath',
516
+ configFilePath,
517
+ ]);
518
+
519
+ expect(mockRun).toHaveBeenCalledWith(
520
+ expect.objectContaining({
521
+ logFilePath: '/path/to/log.txt',
522
+ }),
523
+ );
524
+ });
525
+
526
+ it('should pass maximumPreparingIssuesCount from config file', async () => {
527
+ const configWithCount = {
528
+ ...defaultConfig,
529
+ maximumPreparingIssuesCount: 10,
530
+ };
531
+ writeConfig(configWithCount);
532
+
533
+ const mockRun = jest.fn().mockResolvedValue(undefined);
534
+ const MockedStartPreparationUseCase = jest.mocked(
535
+ StartPreparationUseCase,
536
+ );
537
+
538
+ MockedStartPreparationUseCase.mockImplementation(function (
539
+ this: StartPreparationUseCase,
540
+ ) {
541
+ this.run = mockRun;
542
+ return this;
543
+ });
544
+
545
+ await program.parseAsync([
546
+ 'node',
547
+ 'test',
548
+ 'startDaemon',
549
+ '--configFilePath',
550
+ configFilePath,
551
+ ]);
552
+
553
+ expect(mockRun).toHaveBeenCalledWith(
554
+ expect.objectContaining({
555
+ maximumPreparingIssuesCount: 10,
556
+ }),
557
+ );
558
+ });
559
+
560
+ it('should pass maximumPreparingIssuesCount from CLI overriding config', async () => {
561
+ const mockRun = jest.fn().mockResolvedValue(undefined);
562
+ const MockedStartPreparationUseCase = jest.mocked(
563
+ StartPreparationUseCase,
564
+ );
565
+
566
+ MockedStartPreparationUseCase.mockImplementation(function (
567
+ this: StartPreparationUseCase,
568
+ ) {
569
+ this.run = mockRun;
570
+ return this;
571
+ });
572
+
573
+ await program.parseAsync([
574
+ 'node',
575
+ 'test',
576
+ 'startDaemon',
577
+ '--configFilePath',
578
+ configFilePath,
579
+ '--maximumPreparingIssuesCount',
580
+ '20',
581
+ ]);
582
+
583
+ expect(mockRun).toHaveBeenCalledWith(
584
+ expect.objectContaining({
585
+ maximumPreparingIssuesCount: 20,
586
+ }),
587
+ );
588
+ });
589
+
590
+ it('should exit with error for non-numeric maximumPreparingIssuesCount', async () => {
591
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
592
+ const processExitSpy = jest
593
+ .spyOn(process, 'exit')
594
+ .mockImplementation(() => {
595
+ throw new Error('process.exit called');
596
+ });
597
+
598
+ await expect(
599
+ program.parseAsync([
600
+ 'node',
601
+ 'test',
602
+ 'startDaemon',
603
+ '--configFilePath',
604
+ configFilePath,
605
+ '--maximumPreparingIssuesCount',
606
+ 'abc',
607
+ ]),
608
+ ).rejects.toThrow('process.exit called');
609
+
610
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
611
+ 'Invalid value for --maximumPreparingIssuesCount. It must be a positive integer.',
612
+ );
613
+ expect(processExitSpy).toHaveBeenCalledWith(1);
614
+
615
+ consoleErrorSpy.mockRestore();
616
+ processExitSpy.mockRestore();
617
+ });
618
+
619
+ it('should exit with error for negative maximumPreparingIssuesCount', async () => {
620
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
621
+ const processExitSpy = jest
622
+ .spyOn(process, 'exit')
623
+ .mockImplementation(() => {
624
+ throw new Error('process.exit called');
625
+ });
626
+
627
+ await expect(
628
+ program.parseAsync([
629
+ 'node',
630
+ 'test',
631
+ 'startDaemon',
632
+ '--configFilePath',
633
+ configFilePath,
634
+ '--maximumPreparingIssuesCount',
635
+ '-5',
636
+ ]),
637
+ ).rejects.toThrow('process.exit called');
638
+
639
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
640
+ 'Invalid value for --maximumPreparingIssuesCount. It must be a positive integer.',
641
+ );
642
+ expect(processExitSpy).toHaveBeenCalledWith(1);
643
+
644
+ consoleErrorSpy.mockRestore();
645
+ processExitSpy.mockRestore();
646
+ });
647
+
648
+ it('should exit with error when GH_TOKEN is missing', async () => {
649
+ delete process.env.GH_TOKEN;
650
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
651
+ const processExitSpy = jest
652
+ .spyOn(process, 'exit')
653
+ .mockImplementation(() => {
654
+ throw new Error('process.exit called');
655
+ });
656
+
657
+ await expect(
658
+ program.parseAsync([
659
+ 'node',
660
+ 'test',
661
+ 'startDaemon',
662
+ '--configFilePath',
663
+ configFilePath,
664
+ ]),
665
+ ).rejects.toThrow('process.exit called');
666
+
667
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
668
+ 'GH_TOKEN environment variable is required',
669
+ );
670
+ expect(processExitSpy).toHaveBeenCalledWith(1);
671
+
672
+ consoleErrorSpy.mockRestore();
673
+ processExitSpy.mockRestore();
674
+ });
675
+
676
+ it('should exit with error when projectUrl is missing from both CLI and config', async () => {
677
+ const configWithoutProjectUrl = {
678
+ awaitingWorkspaceStatus: 'Awaiting',
679
+ preparationStatus: 'Preparing',
680
+ defaultAgentName: 'agent1',
681
+ };
682
+ writeConfig(configWithoutProjectUrl);
683
+
684
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
685
+ const processExitSpy = jest
686
+ .spyOn(process, 'exit')
687
+ .mockImplementation(() => {
688
+ throw new Error('process.exit called');
689
+ });
690
+
691
+ await expect(
692
+ program.parseAsync([
693
+ 'node',
694
+ 'test',
695
+ 'startDaemon',
696
+ '--configFilePath',
697
+ configFilePath,
698
+ ]),
699
+ ).rejects.toThrow('process.exit called');
700
+
701
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
702
+ 'projectUrl is required. Provide via --projectUrl, config file, or project README.',
703
+ );
704
+ expect(processExitSpy).toHaveBeenCalledWith(1);
705
+
706
+ consoleErrorSpy.mockRestore();
707
+ processExitSpy.mockRestore();
708
+ });
709
+
710
+ it('should exit with error when awaitingWorkspaceStatus is missing', async () => {
711
+ const configMissing = {
712
+ projectUrl: 'https://github.com/orgs/test/projects/1',
713
+ preparationStatus: 'Preparing',
714
+ defaultAgentName: 'agent1',
715
+ };
716
+ writeConfig(configMissing);
717
+
718
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
719
+ const processExitSpy = jest
720
+ .spyOn(process, 'exit')
721
+ .mockImplementation(() => {
722
+ throw new Error('process.exit called');
723
+ });
724
+
725
+ await expect(
726
+ program.parseAsync([
727
+ 'node',
728
+ 'test',
729
+ 'startDaemon',
730
+ '--configFilePath',
731
+ configFilePath,
732
+ ]),
733
+ ).rejects.toThrow('process.exit called');
734
+
735
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
736
+ 'awaitingWorkspaceStatus is required. Provide via --awaitingWorkspaceStatus, config file, or project README.',
737
+ );
738
+ expect(processExitSpy).toHaveBeenCalledWith(1);
739
+
740
+ consoleErrorSpy.mockRestore();
741
+ processExitSpy.mockRestore();
742
+ });
743
+
744
+ it('should exit with error when preparationStatus is missing', async () => {
745
+ const configMissing = {
746
+ projectUrl: 'https://github.com/orgs/test/projects/1',
747
+ awaitingWorkspaceStatus: 'Awaiting',
748
+ defaultAgentName: 'agent1',
749
+ };
750
+ writeConfig(configMissing);
751
+
752
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
753
+ const processExitSpy = jest
754
+ .spyOn(process, 'exit')
755
+ .mockImplementation(() => {
756
+ throw new Error('process.exit called');
757
+ });
758
+
759
+ await expect(
760
+ program.parseAsync([
761
+ 'node',
762
+ 'test',
763
+ 'startDaemon',
764
+ '--configFilePath',
765
+ configFilePath,
766
+ ]),
767
+ ).rejects.toThrow('process.exit called');
768
+
769
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
770
+ 'preparationStatus is required. Provide via --preparationStatus, config file, or project README.',
771
+ );
772
+ expect(processExitSpy).toHaveBeenCalledWith(1);
773
+
774
+ consoleErrorSpy.mockRestore();
775
+ processExitSpy.mockRestore();
776
+ });
777
+
778
+ it('should exit with error when defaultAgentName is missing', async () => {
779
+ const configMissing = {
780
+ projectUrl: 'https://github.com/orgs/test/projects/1',
781
+ awaitingWorkspaceStatus: 'Awaiting',
782
+ preparationStatus: 'Preparing',
783
+ };
784
+ writeConfig(configMissing);
785
+
786
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
787
+ const processExitSpy = jest
788
+ .spyOn(process, 'exit')
789
+ .mockImplementation(() => {
790
+ throw new Error('process.exit called');
791
+ });
792
+
793
+ await expect(
794
+ program.parseAsync([
795
+ 'node',
796
+ 'test',
797
+ 'startDaemon',
798
+ '--configFilePath',
799
+ configFilePath,
800
+ ]),
801
+ ).rejects.toThrow('process.exit called');
802
+
803
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
804
+ 'defaultAgentName is required. Provide via --defaultAgentName, config file, or project README.',
805
+ );
806
+ expect(processExitSpy).toHaveBeenCalledWith(1);
807
+
808
+ consoleErrorSpy.mockRestore();
809
+ processExitSpy.mockRestore();
810
+ });
811
+
812
+ it('should log maximumPreparingIssuesCount before calling useCase.run', async () => {
813
+ const configWithValues = {
814
+ ...defaultConfig,
815
+ maximumPreparingIssuesCount: 10,
816
+ };
817
+ writeConfig(configWithValues);
818
+
819
+ const callOrder: string[] = [];
820
+ const consoleLogSpy = jest
821
+ .spyOn(console, 'log')
822
+ .mockImplementation(() => {
823
+ callOrder.push('console.log');
824
+ });
825
+ const mockRun = jest.fn().mockImplementation(() => {
826
+ callOrder.push('useCase.run');
827
+ return Promise.resolve(undefined);
828
+ });
829
+ const MockedStartPreparationUseCase = jest.mocked(
830
+ StartPreparationUseCase,
831
+ );
832
+
833
+ MockedStartPreparationUseCase.mockImplementation(function (
834
+ this: StartPreparationUseCase,
835
+ ) {
836
+ this.run = mockRun;
837
+ return this;
838
+ });
839
+
840
+ await program.parseAsync([
841
+ 'node',
842
+ 'test',
843
+ 'startDaemon',
844
+ '--configFilePath',
845
+ configFilePath,
846
+ ]);
847
+
848
+ expect(consoleLogSpy).toHaveBeenCalledWith(
849
+ 'maximumPreparingIssuesCount: 10',
850
+ );
851
+ expect(mockRun).toHaveBeenCalledTimes(1);
852
+ const logIndex = callOrder.indexOf('console.log');
853
+ const runIndex = callOrder.indexOf('useCase.run');
854
+ expect(logIndex).toBeLessThan(runIndex);
855
+
856
+ consoleLogSpy.mockRestore();
857
+ });
858
+
859
+ it('should apply README config overrides', async () => {
860
+ const readmeContent = [
861
+ '# Project',
862
+ '<details>',
863
+ '<summary>config</summary>',
864
+ 'defaultAgentName: readme-agent',
865
+ '</details>',
866
+ ].join('\n');
867
+ mockFetchReturningReadme(readmeContent);
868
+
869
+ const mockRun = jest.fn().mockResolvedValue(undefined);
870
+ const MockedStartPreparationUseCase = jest.mocked(
871
+ StartPreparationUseCase,
872
+ );
873
+
874
+ MockedStartPreparationUseCase.mockImplementation(function (
875
+ this: StartPreparationUseCase,
876
+ ) {
877
+ this.run = mockRun;
878
+ return this;
879
+ });
880
+
881
+ await program.parseAsync([
882
+ 'node',
883
+ 'test',
884
+ 'startDaemon',
885
+ '--configFilePath',
886
+ configFilePath,
887
+ ]);
888
+
889
+ expect(mockRun).toHaveBeenCalledWith(
890
+ expect.objectContaining({
891
+ defaultAgentName: 'readme-agent',
892
+ }),
893
+ );
894
+ });
895
+ });
896
+
897
+ describe('notifyFinishedIssuePreparation', () => {
898
+ it('should read parameters from config file', async () => {
899
+ const mockRun = jest.fn().mockResolvedValue(undefined);
900
+ const MockedNotifyFinishedUseCase = jest.mocked(
901
+ NotifyFinishedIssuePreparationUseCase,
902
+ );
903
+
904
+ MockedNotifyFinishedUseCase.mockImplementation(function (
905
+ this: NotifyFinishedIssuePreparationUseCase,
906
+ ) {
907
+ this.run = mockRun;
908
+ return this;
909
+ });
910
+
911
+ await program.parseAsync([
912
+ 'node',
913
+ 'test',
914
+ 'notifyFinishedIssuePreparation',
915
+ '--configFilePath',
916
+ configFilePath,
917
+ '--issueUrl',
918
+ 'https://github.com/test/repo/issues/1',
919
+ ]);
920
+
921
+ expect(mockRun).toHaveBeenCalledTimes(1);
922
+ expect(mockRun).toHaveBeenCalledWith({
923
+ projectUrl: 'https://github.com/orgs/test/projects/1',
924
+ issueUrl: 'https://github.com/test/repo/issues/1',
925
+ preparationStatus: 'Preparing',
926
+ awaitingWorkspaceStatus: 'Awaiting',
927
+ awaitingQualityCheckStatus: 'Awaiting QC',
928
+ thresholdForAutoReject: 3,
929
+ workflowBlockerResolvedWebhookUrl: null,
930
+ });
931
+ });
932
+
933
+ it('should allow CLI args to override config file values', async () => {
934
+ const mockRun = jest.fn().mockResolvedValue(undefined);
935
+ const MockedNotifyFinishedUseCase = jest.mocked(
936
+ NotifyFinishedIssuePreparationUseCase,
937
+ );
938
+
939
+ MockedNotifyFinishedUseCase.mockImplementation(function (
940
+ this: NotifyFinishedIssuePreparationUseCase,
941
+ ) {
942
+ this.run = mockRun;
943
+ return this;
944
+ });
945
+
946
+ await program.parseAsync([
947
+ 'node',
948
+ 'test',
949
+ 'notifyFinishedIssuePreparation',
950
+ '--configFilePath',
951
+ configFilePath,
952
+ '--issueUrl',
953
+ 'https://github.com/test/repo/issues/1',
954
+ '--projectUrl',
955
+ 'https://github.com/orgs/override/projects/2',
956
+ '--awaitingQualityCheckStatus',
957
+ 'Override QC',
958
+ ]);
959
+
960
+ expect(mockRun).toHaveBeenCalledWith({
961
+ projectUrl: 'https://github.com/orgs/override/projects/2',
962
+ issueUrl: 'https://github.com/test/repo/issues/1',
963
+ preparationStatus: 'Preparing',
964
+ awaitingWorkspaceStatus: 'Awaiting',
965
+ awaitingQualityCheckStatus: 'Override QC',
966
+ thresholdForAutoReject: 3,
967
+ workflowBlockerResolvedWebhookUrl: null,
968
+ });
969
+ });
970
+
971
+ it('should pass custom thresholdForAutoReject from config file', async () => {
972
+ const configWithThreshold = {
973
+ ...defaultConfig,
974
+ thresholdForAutoReject: 5,
975
+ };
976
+ writeConfig(configWithThreshold);
977
+
978
+ const mockRun = jest.fn().mockResolvedValue(undefined);
979
+ const MockedNotifyFinishedUseCase = jest.mocked(
980
+ NotifyFinishedIssuePreparationUseCase,
981
+ );
982
+
983
+ MockedNotifyFinishedUseCase.mockImplementation(function (
984
+ this: NotifyFinishedIssuePreparationUseCase,
985
+ ) {
986
+ this.run = mockRun;
987
+ return this;
988
+ });
989
+
990
+ await program.parseAsync([
991
+ 'node',
992
+ 'test',
993
+ 'notifyFinishedIssuePreparation',
994
+ '--configFilePath',
995
+ configFilePath,
996
+ '--issueUrl',
997
+ 'https://github.com/test/repo/issues/1',
998
+ ]);
999
+
1000
+ expect(mockRun).toHaveBeenCalledWith(
1001
+ expect.objectContaining({
1002
+ thresholdForAutoReject: 5,
1003
+ }),
1004
+ );
1005
+ });
1006
+
1007
+ it('should pass custom thresholdForAutoReject from CLI overriding config', async () => {
1008
+ const mockRun = jest.fn().mockResolvedValue(undefined);
1009
+ const MockedNotifyFinishedUseCase = jest.mocked(
1010
+ NotifyFinishedIssuePreparationUseCase,
1011
+ );
1012
+
1013
+ MockedNotifyFinishedUseCase.mockImplementation(function (
1014
+ this: NotifyFinishedIssuePreparationUseCase,
1015
+ ) {
1016
+ this.run = mockRun;
1017
+ return this;
1018
+ });
1019
+
1020
+ await program.parseAsync([
1021
+ 'node',
1022
+ 'test',
1023
+ 'notifyFinishedIssuePreparation',
1024
+ '--configFilePath',
1025
+ configFilePath,
1026
+ '--issueUrl',
1027
+ 'https://github.com/test/repo/issues/1',
1028
+ '--thresholdForAutoReject',
1029
+ '7',
1030
+ ]);
1031
+
1032
+ expect(mockRun).toHaveBeenCalledWith(
1033
+ expect.objectContaining({
1034
+ thresholdForAutoReject: 7,
1035
+ }),
1036
+ );
1037
+ });
1038
+
1039
+ it('should exit with error for invalid thresholdForAutoReject', async () => {
1040
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1041
+ const processExitSpy = jest
1042
+ .spyOn(process, 'exit')
1043
+ .mockImplementation(() => {
1044
+ throw new Error('process.exit called');
1045
+ });
1046
+
1047
+ await expect(
1048
+ program.parseAsync([
1049
+ 'node',
1050
+ 'test',
1051
+ 'notifyFinishedIssuePreparation',
1052
+ '--configFilePath',
1053
+ configFilePath,
1054
+ '--issueUrl',
1055
+ 'https://github.com/test/repo/issues/1',
1056
+ '--thresholdForAutoReject',
1057
+ 'abc',
1058
+ ]),
1059
+ ).rejects.toThrow('process.exit called');
1060
+
1061
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1062
+ 'Invalid value for --thresholdForAutoReject. It must be a positive integer.',
1063
+ );
1064
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1065
+
1066
+ consoleErrorSpy.mockRestore();
1067
+ processExitSpy.mockRestore();
1068
+ });
1069
+
1070
+ it('should exit with error when GH_TOKEN is missing', async () => {
1071
+ delete process.env.GH_TOKEN;
1072
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1073
+ const processExitSpy = jest
1074
+ .spyOn(process, 'exit')
1075
+ .mockImplementation(() => {
1076
+ throw new Error('process.exit called');
1077
+ });
1078
+
1079
+ await expect(
1080
+ program.parseAsync([
1081
+ 'node',
1082
+ 'test',
1083
+ 'notifyFinishedIssuePreparation',
1084
+ '--configFilePath',
1085
+ configFilePath,
1086
+ '--issueUrl',
1087
+ 'https://github.com/test/repo/issues/1',
1088
+ ]),
1089
+ ).rejects.toThrow('process.exit called');
1090
+
1091
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1092
+ 'GH_TOKEN environment variable is required',
1093
+ );
1094
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1095
+
1096
+ consoleErrorSpy.mockRestore();
1097
+ processExitSpy.mockRestore();
1098
+ });
1099
+
1100
+ it('should exit with error when projectUrl is missing', async () => {
1101
+ const configMissing = {
1102
+ preparationStatus: 'Preparing',
1103
+ awaitingWorkspaceStatus: 'Awaiting',
1104
+ awaitingQualityCheckStatus: 'Awaiting QC',
1105
+ };
1106
+ writeConfig(configMissing);
1107
+
1108
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1109
+ const processExitSpy = jest
1110
+ .spyOn(process, 'exit')
1111
+ .mockImplementation(() => {
1112
+ throw new Error('process.exit called');
1113
+ });
1114
+
1115
+ await expect(
1116
+ program.parseAsync([
1117
+ 'node',
1118
+ 'test',
1119
+ 'notifyFinishedIssuePreparation',
1120
+ '--configFilePath',
1121
+ configFilePath,
1122
+ '--issueUrl',
1123
+ 'https://github.com/test/repo/issues/1',
1124
+ ]),
1125
+ ).rejects.toThrow('process.exit called');
1126
+
1127
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1128
+ 'projectUrl is required. Provide via --projectUrl, config file, or project README.',
1129
+ );
1130
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1131
+
1132
+ consoleErrorSpy.mockRestore();
1133
+ processExitSpy.mockRestore();
1134
+ });
1135
+
1136
+ it('should exit with error when preparationStatus is missing', async () => {
1137
+ const configMissing = {
1138
+ projectUrl: 'https://github.com/orgs/test/projects/1',
1139
+ awaitingWorkspaceStatus: 'Awaiting',
1140
+ awaitingQualityCheckStatus: 'Awaiting QC',
1141
+ };
1142
+ writeConfig(configMissing);
1143
+
1144
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1145
+ const processExitSpy = jest
1146
+ .spyOn(process, 'exit')
1147
+ .mockImplementation(() => {
1148
+ throw new Error('process.exit called');
1149
+ });
1150
+
1151
+ await expect(
1152
+ program.parseAsync([
1153
+ 'node',
1154
+ 'test',
1155
+ 'notifyFinishedIssuePreparation',
1156
+ '--configFilePath',
1157
+ configFilePath,
1158
+ '--issueUrl',
1159
+ 'https://github.com/test/repo/issues/1',
1160
+ ]),
1161
+ ).rejects.toThrow('process.exit called');
1162
+
1163
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1164
+ 'preparationStatus is required. Provide via --preparationStatus, config file, or project README.',
1165
+ );
1166
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1167
+
1168
+ consoleErrorSpy.mockRestore();
1169
+ processExitSpy.mockRestore();
1170
+ });
1171
+
1172
+ it('should exit with error when awaitingWorkspaceStatus is missing', async () => {
1173
+ const configMissing = {
1174
+ projectUrl: 'https://github.com/orgs/test/projects/1',
1175
+ preparationStatus: 'Preparing',
1176
+ awaitingQualityCheckStatus: 'Awaiting QC',
1177
+ };
1178
+ writeConfig(configMissing);
1179
+
1180
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1181
+ const processExitSpy = jest
1182
+ .spyOn(process, 'exit')
1183
+ .mockImplementation(() => {
1184
+ throw new Error('process.exit called');
1185
+ });
1186
+
1187
+ await expect(
1188
+ program.parseAsync([
1189
+ 'node',
1190
+ 'test',
1191
+ 'notifyFinishedIssuePreparation',
1192
+ '--configFilePath',
1193
+ configFilePath,
1194
+ '--issueUrl',
1195
+ 'https://github.com/test/repo/issues/1',
1196
+ ]),
1197
+ ).rejects.toThrow('process.exit called');
1198
+
1199
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1200
+ 'awaitingWorkspaceStatus is required. Provide via --awaitingWorkspaceStatus, config file, or project README.',
1201
+ );
1202
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1203
+
1204
+ consoleErrorSpy.mockRestore();
1205
+ processExitSpy.mockRestore();
1206
+ });
1207
+
1208
+ it('should exit with error when awaitingQualityCheckStatus is missing', async () => {
1209
+ const configMissing = {
1210
+ projectUrl: 'https://github.com/orgs/test/projects/1',
1211
+ preparationStatus: 'Preparing',
1212
+ awaitingWorkspaceStatus: 'Awaiting',
1213
+ };
1214
+ writeConfig(configMissing);
1215
+
1216
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
1217
+ const processExitSpy = jest
1218
+ .spyOn(process, 'exit')
1219
+ .mockImplementation(() => {
1220
+ throw new Error('process.exit called');
1221
+ });
1222
+
1223
+ await expect(
1224
+ program.parseAsync([
1225
+ 'node',
1226
+ 'test',
1227
+ 'notifyFinishedIssuePreparation',
1228
+ '--configFilePath',
1229
+ configFilePath,
1230
+ '--issueUrl',
1231
+ 'https://github.com/test/repo/issues/1',
1232
+ ]),
1233
+ ).rejects.toThrow('process.exit called');
1234
+
1235
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
1236
+ 'awaitingQualityCheckStatus is required. Provide via --awaitingQualityCheckStatus, config file, or project README.',
1237
+ );
1238
+ expect(processExitSpy).toHaveBeenCalledWith(1);
1239
+
1240
+ consoleErrorSpy.mockRestore();
1241
+ processExitSpy.mockRestore();
1242
+ });
1243
+
1244
+ it('should pass workflowBlockerResolvedWebhookUrl from config file', async () => {
1245
+ const configWithWebhook = {
1246
+ ...defaultConfig,
1247
+ workflowBlockerResolvedWebhookUrl:
1248
+ 'https://example.com/webhook?url={URL}',
1249
+ };
1250
+ writeConfig(configWithWebhook);
1251
+
1252
+ const mockRun = jest.fn().mockResolvedValue(undefined);
1253
+ const MockedNotifyFinishedUseCase = jest.mocked(
1254
+ NotifyFinishedIssuePreparationUseCase,
1255
+ );
1256
+
1257
+ MockedNotifyFinishedUseCase.mockImplementation(function (
1258
+ this: NotifyFinishedIssuePreparationUseCase,
1259
+ ) {
1260
+ this.run = mockRun;
1261
+ return this;
1262
+ });
1263
+
1264
+ await program.parseAsync([
1265
+ 'node',
1266
+ 'test',
1267
+ 'notifyFinishedIssuePreparation',
1268
+ '--configFilePath',
1269
+ configFilePath,
1270
+ '--issueUrl',
1271
+ 'https://github.com/test/repo/issues/1',
1272
+ ]);
1273
+
1274
+ expect(mockRun).toHaveBeenCalledWith(
1275
+ expect.objectContaining({
1276
+ workflowBlockerResolvedWebhookUrl:
1277
+ 'https://example.com/webhook?url={URL}',
1278
+ }),
1279
+ );
1280
+ });
1281
+
1282
+ it('should apply README config overrides', async () => {
1283
+ const readmeContent = [
1284
+ '# Project',
1285
+ '<details>',
1286
+ '<summary>config</summary>',
1287
+ "awaitingQualityCheckStatus: 'README QC'",
1288
+ '</details>',
1289
+ ].join('\n');
1290
+ mockFetchReturningReadme(readmeContent);
1291
+
1292
+ const mockRun = jest.fn().mockResolvedValue(undefined);
1293
+ const MockedNotifyFinishedUseCase = jest.mocked(
1294
+ NotifyFinishedIssuePreparationUseCase,
1295
+ );
1296
+
1297
+ MockedNotifyFinishedUseCase.mockImplementation(function (
1298
+ this: NotifyFinishedIssuePreparationUseCase,
1299
+ ) {
1300
+ this.run = mockRun;
1301
+ return this;
1302
+ });
1303
+
1304
+ await program.parseAsync([
1305
+ 'node',
1306
+ 'test',
1307
+ 'notifyFinishedIssuePreparation',
1308
+ '--configFilePath',
1309
+ configFilePath,
1310
+ '--issueUrl',
1311
+ 'https://github.com/test/repo/issues/1',
1312
+ ]);
1313
+
1314
+ expect(mockRun).toHaveBeenCalledWith(
1315
+ expect.objectContaining({
1316
+ awaitingQualityCheckStatus: 'README QC',
1317
+ }),
1318
+ );
1319
+ });
20
1320
  });
21
1321
  });