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,19 +1,318 @@
1
1
  #!/usr/bin/env node
2
+ import YAML from 'yaml';
2
3
  import { Command } from 'commander';
4
+ import * as fs from 'fs';
3
5
  import { HandleScheduledEventUseCaseHandler } from '../handlers/HandleScheduledEventUseCaseHandler';
6
+ import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparationUseCase';
7
+ import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
8
+ import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
9
+ import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
10
+ import { ApiV3IssueRepository } from '../../repositories/issue/ApiV3IssueRepository';
11
+ import { RestIssueRepository } from '../../repositories/issue/RestIssueRepository';
12
+ import { GraphqlProjectItemRepository } from '../../repositories/issue/GraphqlProjectItemRepository';
13
+ import { ApiV3CheerioRestIssueRepository } from '../../repositories/issue/ApiV3CheerioRestIssueRepository';
14
+ import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
15
+ import { CheerioProjectRepository } from '../../repositories/CheerioProjectRepository';
16
+ import { BaseGitHubRepository } from '../../repositories/BaseGitHubRepository';
17
+ import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
18
+ import { OauthAPIClaudeRepository } from '../../repositories/OauthAPIClaudeRepository';
19
+ import { GitHubIssueCommentRepository } from '../../repositories/GitHubIssueCommentRepository';
20
+ import { FetchWebhookRepository } from '../../repositories/FetchWebhookRepository';
21
+ import { Project } from '../../../domain/entities/Project';
4
22
 
5
- const program = new Command();
23
+ type ConfigFile = {
24
+ projectUrl?: string;
25
+ awaitingWorkspaceStatus?: string;
26
+ preparationStatus?: string;
27
+ defaultAgentName?: string;
28
+ logFilePath?: string;
29
+ maximumPreparingIssuesCount?: number;
30
+ allowIssueCacheMinutes?: number;
31
+ awaitingQualityCheckStatus?: string;
32
+ thresholdForAutoReject?: number;
33
+ workflowBlockerResolvedWebhookUrl?: string;
34
+ projectName?: string;
35
+ };
6
36
 
7
- interface Options {
37
+ type StartDaemonOptions = {
38
+ projectUrl?: string;
39
+ awaitingWorkspaceStatus?: string;
40
+ preparationStatus?: string;
41
+ defaultAgentName?: string;
42
+ logFilePath?: string;
43
+ maximumPreparingIssuesCount?: string;
44
+ allowIssueCacheMinutes?: string;
45
+ configFilePath: string;
46
+ };
47
+
48
+ type NotifyFinishedOptions = {
49
+ issueUrl: string;
50
+ projectUrl?: string;
51
+ preparationStatus?: string;
52
+ awaitingWorkspaceStatus?: string;
53
+ awaitingQualityCheckStatus?: string;
54
+ thresholdForAutoReject?: string;
55
+ workflowBlockerResolvedWebhookUrl?: string;
56
+ configFilePath: string;
57
+ };
58
+
59
+ const getStringValue = (
60
+ obj: Record<string, unknown>,
61
+ key: string,
62
+ ): string | undefined => {
63
+ const value = obj[key];
64
+ return typeof value === 'string' ? value : undefined;
65
+ };
66
+
67
+ const getNumberValue = (
68
+ obj: Record<string, unknown>,
69
+ key: string,
70
+ ): number | undefined => {
71
+ const value = obj[key];
72
+ return typeof value === 'number' ? value : undefined;
73
+ };
74
+
75
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
76
+ typeof value === 'object' && value !== null && !Array.isArray(value);
77
+
78
+ export const loadConfigFile = (configFilePath: string): ConfigFile => {
79
+ try {
80
+ const content = fs.readFileSync(configFilePath, 'utf-8');
81
+ const parsed: unknown = YAML.parse(content);
82
+ if (!isRecord(parsed)) {
83
+ return {};
84
+ }
85
+ return {
86
+ projectUrl: getStringValue(parsed, 'projectUrl'),
87
+ awaitingWorkspaceStatus: getStringValue(
88
+ parsed,
89
+ 'awaitingWorkspaceStatus',
90
+ ),
91
+ preparationStatus: getStringValue(parsed, 'preparationStatus'),
92
+ defaultAgentName: getStringValue(parsed, 'defaultAgentName'),
93
+ logFilePath: getStringValue(parsed, 'logFilePath'),
94
+ maximumPreparingIssuesCount: getNumberValue(
95
+ parsed,
96
+ 'maximumPreparingIssuesCount',
97
+ ),
98
+ allowIssueCacheMinutes: getNumberValue(parsed, 'allowIssueCacheMinutes'),
99
+ awaitingQualityCheckStatus: getStringValue(
100
+ parsed,
101
+ 'awaitingQualityCheckStatus',
102
+ ),
103
+ thresholdForAutoReject: getNumberValue(parsed, 'thresholdForAutoReject'),
104
+ workflowBlockerResolvedWebhookUrl: getStringValue(
105
+ parsed,
106
+ 'workflowBlockerResolvedWebhookUrl',
107
+ ),
108
+ projectName: getStringValue(parsed, 'projectName'),
109
+ };
110
+ } catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ console.error(
113
+ `Failed to load configuration file "${configFilePath}": ${message}`,
114
+ );
115
+ process.exit(1);
116
+ }
117
+ };
118
+
119
+ export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
120
+ const detailsRegex =
121
+ /<details>\s*<summary>config<\/summary>([\s\S]*?)<\/details>/i;
122
+ const match = detailsRegex.exec(readme);
123
+ if (!match) {
124
+ return {};
125
+ }
126
+ const yamlContent = match[1].trim();
127
+ if (!yamlContent) {
128
+ return {};
129
+ }
130
+ try {
131
+ const parsed: unknown = YAML.parse(yamlContent);
132
+ if (!isRecord(parsed)) {
133
+ return {};
134
+ }
135
+ return {
136
+ awaitingWorkspaceStatus: getStringValue(
137
+ parsed,
138
+ 'awaitingWorkspaceStatus',
139
+ ),
140
+ preparationStatus: getStringValue(parsed, 'preparationStatus'),
141
+ defaultAgentName: getStringValue(parsed, 'defaultAgentName'),
142
+ logFilePath: getStringValue(parsed, 'logFilePath'),
143
+ maximumPreparingIssuesCount: getNumberValue(
144
+ parsed,
145
+ 'maximumPreparingIssuesCount',
146
+ ),
147
+ allowIssueCacheMinutes: getNumberValue(parsed, 'allowIssueCacheMinutes'),
148
+ awaitingQualityCheckStatus: getStringValue(
149
+ parsed,
150
+ 'awaitingQualityCheckStatus',
151
+ ),
152
+ thresholdForAutoReject: getNumberValue(parsed, 'thresholdForAutoReject'),
153
+ workflowBlockerResolvedWebhookUrl: getStringValue(
154
+ parsed,
155
+ 'workflowBlockerResolvedWebhookUrl',
156
+ ),
157
+ };
158
+ } catch {
159
+ console.warn('Failed to parse YAML from project README config section');
160
+ return {};
161
+ }
162
+ };
163
+
164
+ export const mergeConfigs = (
165
+ configFile: ConfigFile,
166
+ cliOverrides: ConfigFile,
167
+ readmeOverrides: ConfigFile,
168
+ ): ConfigFile => ({
169
+ projectUrl: cliOverrides.projectUrl ?? configFile.projectUrl,
170
+ awaitingWorkspaceStatus:
171
+ readmeOverrides.awaitingWorkspaceStatus ??
172
+ cliOverrides.awaitingWorkspaceStatus ??
173
+ configFile.awaitingWorkspaceStatus,
174
+ preparationStatus:
175
+ readmeOverrides.preparationStatus ??
176
+ cliOverrides.preparationStatus ??
177
+ configFile.preparationStatus,
178
+ defaultAgentName:
179
+ readmeOverrides.defaultAgentName ??
180
+ cliOverrides.defaultAgentName ??
181
+ configFile.defaultAgentName,
182
+ logFilePath:
183
+ readmeOverrides.logFilePath ??
184
+ cliOverrides.logFilePath ??
185
+ configFile.logFilePath,
186
+ maximumPreparingIssuesCount:
187
+ readmeOverrides.maximumPreparingIssuesCount ??
188
+ cliOverrides.maximumPreparingIssuesCount ??
189
+ configFile.maximumPreparingIssuesCount,
190
+ allowIssueCacheMinutes:
191
+ readmeOverrides.allowIssueCacheMinutes ??
192
+ cliOverrides.allowIssueCacheMinutes ??
193
+ configFile.allowIssueCacheMinutes,
194
+ awaitingQualityCheckStatus:
195
+ readmeOverrides.awaitingQualityCheckStatus ??
196
+ cliOverrides.awaitingQualityCheckStatus ??
197
+ configFile.awaitingQualityCheckStatus,
198
+ thresholdForAutoReject:
199
+ readmeOverrides.thresholdForAutoReject ??
200
+ cliOverrides.thresholdForAutoReject ??
201
+ configFile.thresholdForAutoReject,
202
+ workflowBlockerResolvedWebhookUrl:
203
+ readmeOverrides.workflowBlockerResolvedWebhookUrl ??
204
+ cliOverrides.workflowBlockerResolvedWebhookUrl ??
205
+ configFile.workflowBlockerResolvedWebhookUrl,
206
+ projectName: configFile.projectName,
207
+ });
208
+
209
+ type GraphqlProjectV2ReadmeResponse = {
210
+ data?: {
211
+ organization?: { projectV2?: { readme?: string | null } };
212
+ user?: { projectV2?: { readme?: string | null } };
213
+ };
214
+ };
215
+
216
+ const isGraphqlProjectV2ReadmeResponse = (
217
+ value: unknown,
218
+ ): value is GraphqlProjectV2ReadmeResponse => {
219
+ if (!isRecord(value)) return false;
220
+ const data = value['data'];
221
+ if (data !== undefined && !isRecord(data)) return false;
222
+ return true;
223
+ };
224
+
225
+ export const fetchProjectReadme = async (
226
+ projectUrl: string,
227
+ token: string,
228
+ ): Promise<string | null> => {
229
+ try {
230
+ const urlParts = projectUrl.split('/');
231
+ const projectNumber = parseInt(urlParts[urlParts.length - 1], 10);
232
+ const owner = urlParts[urlParts.length - 3];
233
+
234
+ const query = `
235
+ query($owner: String!, $number: Int!) {
236
+ organization(login: $owner) {
237
+ projectV2(number: $number) {
238
+ readme
239
+ }
240
+ }
241
+ user(login: $owner) {
242
+ projectV2(number: $number) {
243
+ readme
244
+ }
245
+ }
246
+ }
247
+ `;
248
+
249
+ const response = await fetch('https://api.github.com/graphql', {
250
+ method: 'POST',
251
+ headers: {
252
+ Authorization: `Bearer ${token}`,
253
+ 'Content-Type': 'application/json',
254
+ },
255
+ body: JSON.stringify({
256
+ query,
257
+ variables: { owner, number: projectNumber },
258
+ }),
259
+ });
260
+
261
+ if (!response.ok) {
262
+ throw new Error(`GraphQL request failed: ${response.status}`);
263
+ }
264
+
265
+ const responseData: unknown = await response.json();
266
+
267
+ if (!isGraphqlProjectV2ReadmeResponse(responseData)) {
268
+ return null;
269
+ }
270
+
271
+ const orgReadme = responseData.data?.organization?.projectV2?.readme;
272
+ const userReadme = responseData.data?.user?.projectV2?.readme;
273
+ const readme =
274
+ typeof orgReadme === 'string'
275
+ ? orgReadme
276
+ : typeof userReadme === 'string'
277
+ ? userReadme
278
+ : null;
279
+
280
+ return readme;
281
+ } catch {
282
+ console.warn('Failed to fetch project README');
283
+ return null;
284
+ }
285
+ };
286
+
287
+ const buildGithubRepositoryParams = (
288
+ localStorageRepository: LocalStorageRepository,
289
+ cachePath: string,
290
+ token: string,
291
+ ): ConstructorParameters<typeof BaseGitHubRepository> => [
292
+ localStorageRepository,
293
+ `${cachePath}/github.com.cookies.json`,
294
+ token,
295
+ undefined,
296
+ undefined,
297
+ undefined,
298
+ ];
299
+
300
+ interface ScheduleOptions {
8
301
  trigger: 'issue' | 'schedule';
9
302
  config: string;
10
303
  issue?: string;
11
304
  verbose: boolean;
12
305
  }
13
306
 
307
+ export const program = new Command();
308
+
14
309
  program
15
310
  .name('github-issue-tower-defence-management')
16
- .description('CLI tool for GitHub Issue Tower Defence Management')
311
+ .description('CLI tool for GitHub Issue Tower Defence Management');
312
+
313
+ program
314
+ .command('schedule', { isDefault: true })
315
+ .description('Handle scheduled events (trigger: issue or schedule)')
17
316
  .requiredOption(
18
317
  '-t, --trigger <type>',
19
318
  'Trigger type: issue or schedule',
@@ -22,7 +321,7 @@ program
22
321
  .requiredOption('-c, --config <path>', 'Path to config YAML file')
23
322
  .option('-v, --verbose', 'Verbose output')
24
323
  .option('-i, --issue <url>', 'GitHub Issue URL')
25
- .action(async (options: Options) => {
324
+ .action(async (options: ScheduleOptions) => {
26
325
  if (options.trigger === 'issue' && !options.issue) {
27
326
  console.error('Issue URL is required when trigger type is "issue"');
28
327
  process.exit(1);
@@ -33,6 +332,350 @@ program
33
332
  }
34
333
  });
35
334
 
36
- if (process.argv) {
335
+ program
336
+ .command('startDaemon')
337
+ .description('Start daemon to prepare GitHub issues')
338
+ .requiredOption(
339
+ '--configFilePath <path>',
340
+ 'Path to config file for tower defence management',
341
+ )
342
+ .option('--projectUrl <url>', 'GitHub project URL')
343
+ .option(
344
+ '--awaitingWorkspaceStatus <status>',
345
+ 'Status for issues awaiting workspace',
346
+ )
347
+ .option('--preparationStatus <status>', 'Status for issues in preparation')
348
+ .option('--defaultAgentName <name>', 'Default agent name')
349
+ .option('--logFilePath <path>', 'Path to log file')
350
+ .option(
351
+ '--maximumPreparingIssuesCount <count>',
352
+ 'Maximum number of issues in preparation status (default: 6)',
353
+ )
354
+ .option(
355
+ '--allowIssueCacheMinutes <minutes>',
356
+ 'Allow cache for issues in minutes (default: 0)',
357
+ )
358
+ .action(async (options: StartDaemonOptions) => {
359
+ const token = process.env.GH_TOKEN;
360
+ if (!token) {
361
+ console.error('GH_TOKEN environment variable is required');
362
+ process.exit(1);
363
+ }
364
+
365
+ const configFileValues = loadConfigFile(options.configFilePath);
366
+
367
+ const cliOverrides: ConfigFile = {
368
+ projectUrl: options.projectUrl,
369
+ awaitingWorkspaceStatus: options.awaitingWorkspaceStatus,
370
+ preparationStatus: options.preparationStatus,
371
+ defaultAgentName: options.defaultAgentName,
372
+ logFilePath: options.logFilePath,
373
+ maximumPreparingIssuesCount: options.maximumPreparingIssuesCount
374
+ ? Number(options.maximumPreparingIssuesCount)
375
+ : undefined,
376
+ allowIssueCacheMinutes: options.allowIssueCacheMinutes
377
+ ? Number(options.allowIssueCacheMinutes)
378
+ : undefined,
379
+ };
380
+
381
+ const tempProjectUrl =
382
+ cliOverrides.projectUrl ?? configFileValues.projectUrl;
383
+
384
+ let readmeOverrides: ConfigFile = {};
385
+ if (tempProjectUrl) {
386
+ const readme = await fetchProjectReadme(tempProjectUrl, token);
387
+ if (readme) {
388
+ readmeOverrides = parseProjectReadmeConfig(readme);
389
+ }
390
+ }
391
+
392
+ const config = mergeConfigs(
393
+ configFileValues,
394
+ cliOverrides,
395
+ readmeOverrides,
396
+ );
397
+
398
+ const projectUrl = config.projectUrl;
399
+ const awaitingWorkspaceStatus = config.awaitingWorkspaceStatus;
400
+ const preparationStatus = config.preparationStatus;
401
+ const defaultAgentName = config.defaultAgentName;
402
+ const logFilePath = config.logFilePath;
403
+
404
+ if (!projectUrl) {
405
+ console.error(
406
+ 'projectUrl is required. Provide via --projectUrl, config file, or project README.',
407
+ );
408
+ process.exit(1);
409
+ }
410
+ if (!awaitingWorkspaceStatus) {
411
+ console.error(
412
+ 'awaitingWorkspaceStatus is required. Provide via --awaitingWorkspaceStatus, config file, or project README.',
413
+ );
414
+ process.exit(1);
415
+ }
416
+ if (!preparationStatus) {
417
+ console.error(
418
+ 'preparationStatus is required. Provide via --preparationStatus, config file, or project README.',
419
+ );
420
+ process.exit(1);
421
+ }
422
+ if (!defaultAgentName) {
423
+ console.error(
424
+ 'defaultAgentName is required. Provide via --defaultAgentName, config file, or project README.',
425
+ );
426
+ process.exit(1);
427
+ }
428
+
429
+ let maximumPreparingIssuesCount: number | null = null;
430
+ const rawMaxCount = config.maximumPreparingIssuesCount;
431
+ if (rawMaxCount !== undefined) {
432
+ const parsedCount = Number(rawMaxCount);
433
+ if (
434
+ !Number.isFinite(parsedCount) ||
435
+ !Number.isInteger(parsedCount) ||
436
+ parsedCount <= 0
437
+ ) {
438
+ console.error(
439
+ 'Invalid value for --maximumPreparingIssuesCount. It must be a positive integer.',
440
+ );
441
+ process.exit(1);
442
+ }
443
+ maximumPreparingIssuesCount = parsedCount;
444
+ }
445
+
446
+ const allowIssueCacheMinutes = config.allowIssueCacheMinutes ?? 0;
447
+
448
+ console.log(
449
+ `maximumPreparingIssuesCount: ${maximumPreparingIssuesCount ?? 'null (default: 6)'}`,
450
+ );
451
+
452
+ const projectName = config.projectName ?? 'default';
453
+ const localStorageRepository = new LocalStorageRepository();
454
+ const cachePath = `./tmp/cache/${projectName}`;
455
+ const localStorageCacheRepository = new LocalStorageCacheRepository(
456
+ localStorageRepository,
457
+ cachePath,
458
+ );
459
+ const githubRepositoryParams = buildGithubRepositoryParams(
460
+ localStorageRepository,
461
+ cachePath,
462
+ token,
463
+ );
464
+ const projectRepository = {
465
+ ...new GraphqlProjectRepository(...githubRepositoryParams),
466
+ ...new CheerioProjectRepository(...githubRepositoryParams),
467
+ };
468
+ const apiV3IssueRepository = new ApiV3IssueRepository(
469
+ ...githubRepositoryParams,
470
+ );
471
+ const restIssueRepository = new RestIssueRepository(
472
+ ...githubRepositoryParams,
473
+ );
474
+ const graphqlProjectItemRepository = new GraphqlProjectItemRepository(
475
+ ...githubRepositoryParams,
476
+ );
477
+ const issueRepository = new ApiV3CheerioRestIssueRepository(
478
+ apiV3IssueRepository,
479
+ restIssueRepository,
480
+ graphqlProjectItemRepository,
481
+ localStorageCacheRepository,
482
+ ...githubRepositoryParams,
483
+ );
484
+ const claudeRepository = new OauthAPIClaudeRepository();
485
+ const localCommandRunner = new NodeLocalCommandRunner();
486
+
487
+ const useCase = new StartPreparationUseCase(
488
+ projectRepository,
489
+ issueRepository,
490
+ claudeRepository,
491
+ localCommandRunner,
492
+ );
493
+
494
+ await useCase.run({
495
+ projectUrl,
496
+ awaitingWorkspaceStatus,
497
+ preparationStatus,
498
+ defaultAgentName,
499
+ logFilePath: logFilePath ?? undefined,
500
+ maximumPreparingIssuesCount,
501
+ allowIssueCacheMinutes,
502
+ });
503
+ });
504
+
505
+ program
506
+ .command('notifyFinishedIssuePreparation')
507
+ .description('Notify that issue preparation is finished')
508
+ .requiredOption(
509
+ '--configFilePath <path>',
510
+ 'Path to config file for tower defence management',
511
+ )
512
+ .requiredOption('--issueUrl <url>', 'GitHub issue URL')
513
+ .option('--projectUrl <url>', 'GitHub project URL')
514
+ .option('--preparationStatus <status>', 'Status for issues in preparation')
515
+ .option(
516
+ '--awaitingWorkspaceStatus <status>',
517
+ 'Status for issues awaiting workspace',
518
+ )
519
+ .option(
520
+ '--awaitingQualityCheckStatus <status>',
521
+ 'Status for issues awaiting quality check',
522
+ )
523
+ .option(
524
+ '--thresholdForAutoReject <count>',
525
+ 'Threshold for auto-escalation after consecutive rejections (default: 3)',
526
+ )
527
+ .option(
528
+ '--workflowBlockerResolvedWebhookUrl <url>',
529
+ 'Webhook URL to notify when a workflow blocker issue status changes to awaiting quality check. Supports {URL} and {MESSAGE} placeholders.',
530
+ )
531
+ .action(async (options: NotifyFinishedOptions) => {
532
+ const token = process.env.GH_TOKEN;
533
+ if (!token) {
534
+ console.error('GH_TOKEN environment variable is required');
535
+ process.exit(1);
536
+ }
537
+
538
+ const configFileValues = loadConfigFile(options.configFilePath);
539
+
540
+ const cliOverrides: ConfigFile = {
541
+ projectUrl: options.projectUrl,
542
+ preparationStatus: options.preparationStatus,
543
+ awaitingWorkspaceStatus: options.awaitingWorkspaceStatus,
544
+ awaitingQualityCheckStatus: options.awaitingQualityCheckStatus,
545
+ thresholdForAutoReject: options.thresholdForAutoReject
546
+ ? Number(options.thresholdForAutoReject)
547
+ : undefined,
548
+ workflowBlockerResolvedWebhookUrl:
549
+ options.workflowBlockerResolvedWebhookUrl,
550
+ };
551
+
552
+ const tempProjectUrl =
553
+ cliOverrides.projectUrl ?? configFileValues.projectUrl;
554
+
555
+ let readmeOverrides: ConfigFile = {};
556
+ if (tempProjectUrl) {
557
+ const readme = await fetchProjectReadme(tempProjectUrl, token);
558
+ if (readme) {
559
+ readmeOverrides = parseProjectReadmeConfig(readme);
560
+ }
561
+ }
562
+
563
+ const config = mergeConfigs(
564
+ configFileValues,
565
+ cliOverrides,
566
+ readmeOverrides,
567
+ );
568
+
569
+ const projectUrl = config.projectUrl;
570
+ const preparationStatus = config.preparationStatus;
571
+ const awaitingWorkspaceStatus = config.awaitingWorkspaceStatus;
572
+ const awaitingQualityCheckStatus = config.awaitingQualityCheckStatus;
573
+
574
+ if (!projectUrl) {
575
+ console.error(
576
+ 'projectUrl is required. Provide via --projectUrl, config file, or project README.',
577
+ );
578
+ process.exit(1);
579
+ }
580
+ if (!preparationStatus) {
581
+ console.error(
582
+ 'preparationStatus is required. Provide via --preparationStatus, config file, or project README.',
583
+ );
584
+ process.exit(1);
585
+ }
586
+ if (!awaitingWorkspaceStatus) {
587
+ console.error(
588
+ 'awaitingWorkspaceStatus is required. Provide via --awaitingWorkspaceStatus, config file, or project README.',
589
+ );
590
+ process.exit(1);
591
+ }
592
+ if (!awaitingQualityCheckStatus) {
593
+ console.error(
594
+ 'awaitingQualityCheckStatus is required. Provide via --awaitingQualityCheckStatus, config file, or project README.',
595
+ );
596
+ process.exit(1);
597
+ }
598
+
599
+ let thresholdForAutoReject = 3;
600
+ const rawThreshold = config.thresholdForAutoReject;
601
+ if (rawThreshold !== undefined) {
602
+ const parsed = Number(rawThreshold);
603
+ if (
604
+ !Number.isFinite(parsed) ||
605
+ !Number.isInteger(parsed) ||
606
+ parsed <= 0
607
+ ) {
608
+ console.error(
609
+ 'Invalid value for --thresholdForAutoReject. It must be a positive integer.',
610
+ );
611
+ process.exit(1);
612
+ }
613
+ thresholdForAutoReject = parsed;
614
+ }
615
+
616
+ const workflowBlockerResolvedWebhookUrl: string | null =
617
+ config.workflowBlockerResolvedWebhookUrl ?? null;
618
+
619
+ const projectName = config.projectName ?? 'default';
620
+ const localStorageRepository = new LocalStorageRepository();
621
+ const cachePath = `./tmp/cache/${projectName}`;
622
+ const localStorageCacheRepository = new LocalStorageCacheRepository(
623
+ localStorageRepository,
624
+ cachePath,
625
+ );
626
+ const githubRepositoryParams = buildGithubRepositoryParams(
627
+ localStorageRepository,
628
+ cachePath,
629
+ token,
630
+ );
631
+ const projectRepository = {
632
+ ...new GraphqlProjectRepository(...githubRepositoryParams),
633
+ ...new CheerioProjectRepository(...githubRepositoryParams),
634
+ prepareStatus: async (
635
+ _name: string,
636
+ project: Project,
637
+ ): Promise<Project> => {
638
+ return project;
639
+ },
640
+ };
641
+ const apiV3IssueRepository = new ApiV3IssueRepository(
642
+ ...githubRepositoryParams,
643
+ );
644
+ const restIssueRepository = new RestIssueRepository(
645
+ ...githubRepositoryParams,
646
+ );
647
+ const graphqlProjectItemRepository = new GraphqlProjectItemRepository(
648
+ ...githubRepositoryParams,
649
+ );
650
+ const issueRepository = new ApiV3CheerioRestIssueRepository(
651
+ apiV3IssueRepository,
652
+ restIssueRepository,
653
+ graphqlProjectItemRepository,
654
+ localStorageCacheRepository,
655
+ ...githubRepositoryParams,
656
+ );
657
+ const issueCommentRepository = new GitHubIssueCommentRepository(token);
658
+ const webhookRepository = new FetchWebhookRepository();
659
+
660
+ const useCase = new NotifyFinishedIssuePreparationUseCase(
661
+ projectRepository,
662
+ issueRepository,
663
+ issueCommentRepository,
664
+ webhookRepository,
665
+ );
666
+
667
+ await useCase.run({
668
+ projectUrl,
669
+ issueUrl: options.issueUrl,
670
+ preparationStatus,
671
+ awaitingWorkspaceStatus,
672
+ awaitingQualityCheckStatus,
673
+ thresholdForAutoReject,
674
+ workflowBlockerResolvedWebhookUrl,
675
+ });
676
+ });
677
+
678
+ /* istanbul ignore next */
679
+ if (process.argv && require.main === module) {
37
680
  program.parse(process.argv);
38
681
  }
@@ -90,6 +90,20 @@ jest.mock('../../repositories/NodeLocalCommandRunner', () => ({
90
90
  jest.mock('../../repositories/StubClaudeRepository', () => ({
91
91
  StubClaudeRepository: jest.fn().mockImplementation(() => ({})),
92
92
  }));
93
+ jest.mock(
94
+ '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase',
95
+ () => ({
96
+ NotifyFinishedIssuePreparationUseCase: jest
97
+ .fn()
98
+ .mockImplementation(() => ({})),
99
+ }),
100
+ );
101
+ jest.mock('../../repositories/GitHubIssueCommentRepository', () => ({
102
+ GitHubIssueCommentRepository: jest.fn().mockImplementation(() => ({})),
103
+ }));
104
+ jest.mock('../../repositories/FetchWebhookRepository', () => ({
105
+ FetchWebhookRepository: jest.fn().mockImplementation(() => ({})),
106
+ }));
93
107
 
94
108
  import { HandleScheduledEventUseCaseHandler } from './HandleScheduledEventUseCaseHandler';
95
109
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';