github-issue-tower-defence-management 1.173.2 → 1.174.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 (72) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +15 -0
  3. package/bin/adapter/entry-points/cli/index.js +5 -3
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleGithubTokenResolver.js +16 -5
  6. package/bin/adapter/entry-points/console/consoleGithubTokenResolver.js.map +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BcnLfodS.js → index-g4SqIzCh.js} +2 -2
  8. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  9. package/bin/adapter/entry-points/console/webServer.js +16 -2
  10. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
  12. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +1 -1
  14. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  15. package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js +7 -12
  16. package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js.map +1 -1
  17. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +26 -3
  18. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  19. package/bin/domain/usecases/HandleScheduledEventUseCase.js +10 -5
  20. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  21. package/bin/domain/usecases/TriagerApprovalDispatchUseCase.js +142 -0
  22. package/bin/domain/usecases/TriagerApprovalDispatchUseCase.js.map +1 -0
  23. package/bin/domain/usecases/agentReportPrefix.js +5 -0
  24. package/bin/domain/usecases/agentReportPrefix.js.map +1 -0
  25. package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js +2 -2
  26. package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js.map +1 -1
  27. package/bin/domain/usecases/isRecord.js +6 -0
  28. package/bin/domain/usecases/isRecord.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/adapter/entry-points/cli/index.ts +8 -4
  31. package/src/adapter/entry-points/console/consoleGithubTokenResolver.test.ts +29 -2
  32. package/src/adapter/entry-points/console/consoleGithubTokenResolver.ts +21 -4
  33. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +5 -1
  34. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.test.ts +8 -5
  35. package/src/adapter/entry-points/console/ui/src/features/console/lib/imageProxy.ts +2 -2
  36. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +2 -2
  37. package/src/adapter/entry-points/console/ui-dist/assets/{index-BcnLfodS.js → index-g4SqIzCh.js} +2 -2
  38. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  39. package/src/adapter/entry-points/console/webServer.test.ts +56 -5
  40. package/src/adapter/entry-points/console/webServer.ts +20 -3
  41. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -0
  42. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +1 -0
  43. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +53 -70
  44. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +10 -13
  45. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +142 -0
  46. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +41 -0
  47. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +27 -0
  48. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -3
  49. package/src/domain/usecases/TriagerApprovalDispatchUseCase.test.ts +879 -0
  50. package/src/domain/usecases/TriagerApprovalDispatchUseCase.ts +247 -0
  51. package/src/domain/usecases/agentReportPrefix.ts +1 -0
  52. package/src/domain/usecases/isPullRequestDeclaredUnnecessary.ts +1 -1
  53. package/src/domain/usecases/isRecord.ts +2 -0
  54. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  55. package/types/adapter/entry-points/console/consoleGithubTokenResolver.d.ts +1 -0
  56. package/types/adapter/entry-points/console/consoleGithubTokenResolver.d.ts.map +1 -1
  57. package/types/adapter/entry-points/console/webServer.d.ts +2 -1
  58. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  59. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  60. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  61. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +3 -2
  62. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -1
  63. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +3 -1
  64. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  65. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  66. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  67. package/types/domain/usecases/TriagerApprovalDispatchUseCase.d.ts +15 -0
  68. package/types/domain/usecases/TriagerApprovalDispatchUseCase.d.ts.map +1 -0
  69. package/types/domain/usecases/agentReportPrefix.d.ts +2 -0
  70. package/types/domain/usecases/agentReportPrefix.d.ts.map +1 -0
  71. package/types/domain/usecases/isRecord.d.ts +2 -0
  72. package/types/domain/usecases/isRecord.d.ts.map +1 -0
@@ -0,0 +1,879 @@
1
+ import { TriagerApprovalDispatchUseCase } from './TriagerApprovalDispatchUseCase';
2
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
3
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
5
+ import { Comment } from '../entities/Comment';
6
+ import { Issue } from '../entities/Issue';
7
+ import { Project } from '../entities/Project';
8
+
9
+ type MockedProjectRepository = jest.Mocked<
10
+ Pick<
11
+ ProjectRepository,
12
+ | 'findProjectIdByUrl'
13
+ | 'getProject'
14
+ | 'createField'
15
+ | 'getByUrl'
16
+ | 'updateAgentList'
17
+ >
18
+ >;
19
+ type MockedIssueRepository = jest.Mocked<
20
+ Pick<
21
+ IssueRepository,
22
+ 'getAllIssues' | 'updateStatus' | 'updateStory' | 'setIssueAgentField'
23
+ >
24
+ >;
25
+ type MockedIssueCommentRepository = jest.Mocked<
26
+ Pick<IssueCommentRepository, 'getCommentsFromIssue' | 'createComment'>
27
+ >;
28
+
29
+ const TRIAGER_PROPOSAL_COMMENT = (
30
+ agent: string,
31
+ story: string,
32
+ storyAlreadySet: boolean,
33
+ ): string =>
34
+ `From: :robot: triager (claude-sonnet-4-6)\n\`\`\`json\n{ "pullRequestRequired": false, "nextStep": null }\n\`\`\`\n\n## Proposal\n\n\`\`\`json\n${JSON.stringify({ triagerProposal: { recommendedAgent: agent, recommendedStory: story, storyAlreadySet } })}\n\`\`\``;
35
+
36
+ const createComment = (
37
+ author: string,
38
+ content: string,
39
+ createdAt = new Date('2000-01-01T00:00:00Z'),
40
+ ): Comment => ({ author, content, createdAt });
41
+
42
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
43
+ nameWithOwner: 'owner/repo',
44
+ number: 1,
45
+ title: 'Test Issue',
46
+ state: 'OPEN',
47
+ status: 'Awaiting Quality Check',
48
+ story: null,
49
+ nextActionDate: null,
50
+ nextActionHour: null,
51
+ estimationMinutes: null,
52
+ dependedIssueUrls: [],
53
+ completionDate50PercentConfidence: null,
54
+ url: 'https://github.com/owner/repo/issues/1',
55
+ assignees: [],
56
+ labels: [],
57
+ org: 'owner',
58
+ repo: 'repo',
59
+ body: '',
60
+ itemId: 'item-1',
61
+ isPr: false,
62
+ isInProgress: false,
63
+ isClosed: false,
64
+ createdAt: new Date('2000-01-01T00:00:00Z'),
65
+ author: 'owner-user',
66
+ closingIssueReferenceUrls: [],
67
+ agent: null,
68
+ ...overrides,
69
+ });
70
+
71
+ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
72
+ id: 'project-1',
73
+ url: 'https://github.com/orgs/owner/projects/1',
74
+ databaseId: 1,
75
+ name: 'Test Project',
76
+ status: {
77
+ name: 'Status',
78
+ fieldId: 'status-field-id',
79
+ statuses: [
80
+ {
81
+ id: 'awaiting-workspace-id',
82
+ name: 'Awaiting Workspace',
83
+ color: 'BLUE',
84
+ description: '',
85
+ },
86
+ {
87
+ id: 'awaiting-quality-check-id',
88
+ name: 'Awaiting Quality Check',
89
+ color: 'GREEN',
90
+ description: '',
91
+ },
92
+ ],
93
+ },
94
+ nextActionDate: null,
95
+ nextActionHour: null,
96
+ story: {
97
+ name: 'Story',
98
+ fieldId: 'story-field-id',
99
+ databaseId: 2,
100
+ stories: [
101
+ {
102
+ id: 'story-regular-workflow-id',
103
+ name: 'regular / workflow improvement',
104
+ color: 'GRAY',
105
+ description: '',
106
+ },
107
+ ],
108
+ workflowManagementStory: {
109
+ id: 'wf-story-id',
110
+ name: 'workflow management',
111
+ },
112
+ },
113
+ remainingEstimationMinutes: null,
114
+ dependedIssueUrlSeparatedByComma: null,
115
+ completionDate50PercentConfidence: null,
116
+ agent: {
117
+ name: 'Agent',
118
+ fieldId: 'agent-field-id',
119
+ options: [
120
+ {
121
+ id: 'developer-option-id',
122
+ name: 'developer',
123
+ color: 'GRAY',
124
+ description: '',
125
+ },
126
+ ],
127
+ },
128
+ ...overrides,
129
+ });
130
+
131
+ describe('TriagerApprovalDispatchUseCase', () => {
132
+ let useCase: TriagerApprovalDispatchUseCase;
133
+ let mockProjectRepository: MockedProjectRepository;
134
+ let mockIssueRepository: MockedIssueRepository;
135
+ let mockIssueCommentRepository: MockedIssueCommentRepository;
136
+ let mockProject: Project;
137
+
138
+ beforeEach(() => {
139
+ jest.resetAllMocks();
140
+ mockProject = createMockProject();
141
+ mockProjectRepository = {
142
+ findProjectIdByUrl: jest.fn().mockResolvedValue('project-1'),
143
+ getProject: jest.fn().mockResolvedValue(mockProject),
144
+ createField: jest.fn().mockResolvedValue(undefined),
145
+ getByUrl: jest.fn().mockResolvedValue(mockProject),
146
+ updateAgentList: jest.fn().mockResolvedValue([]),
147
+ };
148
+ mockIssueRepository = {
149
+ getAllIssues: jest.fn().mockResolvedValue({
150
+ project: mockProject,
151
+ issues: [],
152
+ cacheUsed: false,
153
+ }),
154
+ updateStatus: jest.fn().mockResolvedValue(undefined),
155
+ updateStory: jest.fn().mockResolvedValue(undefined),
156
+ setIssueAgentField: jest.fn().mockResolvedValue(undefined),
157
+ };
158
+ mockIssueCommentRepository = {
159
+ getCommentsFromIssue: jest.fn().mockResolvedValue([]),
160
+ createComment: jest.fn().mockResolvedValue(undefined),
161
+ };
162
+ useCase = new TriagerApprovalDispatchUseCase(
163
+ mockProjectRepository,
164
+ mockIssueRepository,
165
+ mockIssueCommentRepository,
166
+ );
167
+ });
168
+
169
+ describe('run', () => {
170
+ it('should do nothing when no issues are in candidate statuses', async () => {
171
+ const irrelevantIssue = createMockIssue({
172
+ status: 'Preparation',
173
+ author: 'owner-user',
174
+ });
175
+ mockIssueRepository.getAllIssues.mockResolvedValue({
176
+ project: mockProject,
177
+ issues: [irrelevantIssue],
178
+ cacheUsed: false,
179
+ });
180
+
181
+ await useCase.run({
182
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
183
+ allowedIssueAuthors: ['owner-user'],
184
+ });
185
+
186
+ expect(
187
+ mockIssueCommentRepository.getCommentsFromIssue.mock.calls,
188
+ ).toEqual([]);
189
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
190
+ });
191
+
192
+ it('should do nothing when the triager proposal comment has no machine-readable block', async () => {
193
+ const issue = createMockIssue({
194
+ status: 'Awaiting Quality Check',
195
+ author: 'owner-user',
196
+ });
197
+ mockIssueRepository.getAllIssues.mockResolvedValue({
198
+ project: mockProject,
199
+ issues: [issue],
200
+ cacheUsed: false,
201
+ });
202
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
203
+ createComment(
204
+ 'bot',
205
+ 'From: :robot: triager (claude-sonnet-4-6)\n```json\n{ "pullRequestRequired": false, "nextStep": null }\n```\n\nRecommended agent: `developer`\nRecommended story: regular / workflow improvement',
206
+ ),
207
+ createComment('owner-user', 'ok'),
208
+ ]);
209
+
210
+ await useCase.run({
211
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
212
+ allowedIssueAuthors: ['owner-user'],
213
+ });
214
+
215
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
216
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
217
+ });
218
+
219
+ it('should do nothing when there is a proposal but no approval comment', async () => {
220
+ const issue = createMockIssue({
221
+ status: 'Awaiting Quality Check',
222
+ author: 'owner-user',
223
+ });
224
+ mockIssueRepository.getAllIssues.mockResolvedValue({
225
+ project: mockProject,
226
+ issues: [issue],
227
+ cacheUsed: false,
228
+ });
229
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
230
+ createComment(
231
+ 'bot',
232
+ TRIAGER_PROPOSAL_COMMENT(
233
+ 'developer',
234
+ 'regular / workflow improvement',
235
+ false,
236
+ ),
237
+ ),
238
+ ]);
239
+
240
+ await useCase.run({
241
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
242
+ allowedIssueAuthors: ['owner-user'],
243
+ });
244
+
245
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
246
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
247
+ });
248
+
249
+ it('should do nothing when the approval comment is posted before the proposal', async () => {
250
+ const issue = createMockIssue({
251
+ status: 'Awaiting Quality Check',
252
+ author: 'owner-user',
253
+ });
254
+ mockIssueRepository.getAllIssues.mockResolvedValue({
255
+ project: mockProject,
256
+ issues: [issue],
257
+ cacheUsed: false,
258
+ });
259
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
260
+ createComment('owner-user', 'ok'),
261
+ createComment(
262
+ 'bot',
263
+ TRIAGER_PROPOSAL_COMMENT(
264
+ 'developer',
265
+ 'regular / workflow improvement',
266
+ false,
267
+ ),
268
+ ),
269
+ ]);
270
+
271
+ await useCase.run({
272
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
273
+ allowedIssueAuthors: ['owner-user'],
274
+ });
275
+
276
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
277
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
278
+ });
279
+
280
+ it('should skip the story update when the proposal reports storyAlreadySet true', async () => {
281
+ const issue = createMockIssue({
282
+ status: 'Awaiting Quality Check',
283
+ author: 'owner-user',
284
+ story: 'regular / workflow improvement',
285
+ });
286
+ mockIssueRepository.getAllIssues.mockResolvedValue({
287
+ project: mockProject,
288
+ issues: [issue],
289
+ cacheUsed: false,
290
+ });
291
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
292
+ createComment(
293
+ 'bot',
294
+ TRIAGER_PROPOSAL_COMMENT(
295
+ 'developer',
296
+ 'regular / workflow improvement',
297
+ true,
298
+ ),
299
+ ),
300
+ createComment('owner-user', 'ok'),
301
+ ]);
302
+
303
+ await useCase.run({
304
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
305
+ allowedIssueAuthors: ['owner-user'],
306
+ });
307
+
308
+ expect(mockIssueRepository.updateStory.mock.calls).toEqual([]);
309
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([
310
+ [
311
+ 'https://github.com/owner/repo/issues/1',
312
+ mockProject,
313
+ 'developer-option-id',
314
+ ],
315
+ ]);
316
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([
317
+ [mockProject, issue, 'awaiting-workspace-id'],
318
+ ]);
319
+ expect(mockIssueCommentRepository.createComment.mock.calls).toHaveLength(
320
+ 1,
321
+ );
322
+ });
323
+
324
+ it('should skip the issue when the recommended agent name cannot be resolved', async () => {
325
+ const issue = createMockIssue({
326
+ status: 'Awaiting Quality Check',
327
+ author: 'owner-user',
328
+ });
329
+ mockIssueRepository.getAllIssues.mockResolvedValue({
330
+ project: mockProject,
331
+ issues: [issue],
332
+ cacheUsed: false,
333
+ });
334
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
335
+ createComment(
336
+ 'bot',
337
+ TRIAGER_PROPOSAL_COMMENT(
338
+ 'unknown-agent-xyz',
339
+ 'regular / workflow improvement',
340
+ false,
341
+ ),
342
+ ),
343
+ createComment('owner-user', 'ok'),
344
+ ]);
345
+ mockProjectRepository.updateAgentList.mockResolvedValue([]);
346
+
347
+ await useCase.run({
348
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
349
+ allowedIssueAuthors: ['owner-user'],
350
+ });
351
+
352
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
353
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
354
+ expect(mockIssueCommentRepository.createComment.mock.calls).toEqual([]);
355
+ });
356
+
357
+ it('should not treat a comment from an author not in the allowed list as an approval', async () => {
358
+ const issue = createMockIssue({
359
+ status: 'Awaiting Quality Check',
360
+ author: 'owner-user',
361
+ });
362
+ mockIssueRepository.getAllIssues.mockResolvedValue({
363
+ project: mockProject,
364
+ issues: [issue],
365
+ cacheUsed: false,
366
+ });
367
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
368
+ createComment(
369
+ 'bot',
370
+ TRIAGER_PROPOSAL_COMMENT(
371
+ 'developer',
372
+ 'regular / workflow improvement',
373
+ false,
374
+ ),
375
+ ),
376
+ createComment('external-user', 'ok'),
377
+ ]);
378
+
379
+ await useCase.run({
380
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
381
+ allowedIssueAuthors: ['owner-user'],
382
+ });
383
+
384
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
385
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
386
+ });
387
+
388
+ it('should set story, agent, and status to Awaiting Workspace on approval', async () => {
389
+ const issue = createMockIssue({
390
+ status: 'Awaiting Quality Check',
391
+ author: 'owner-user',
392
+ });
393
+ mockIssueRepository.getAllIssues.mockResolvedValue({
394
+ project: mockProject,
395
+ issues: [issue],
396
+ cacheUsed: false,
397
+ });
398
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
399
+ createComment(
400
+ 'bot',
401
+ TRIAGER_PROPOSAL_COMMENT(
402
+ 'developer',
403
+ 'regular / workflow improvement',
404
+ false,
405
+ ),
406
+ ),
407
+ createComment('owner-user', 'ok'),
408
+ ]);
409
+
410
+ await useCase.run({
411
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
412
+ allowedIssueAuthors: ['owner-user'],
413
+ });
414
+
415
+ expect(mockIssueRepository.updateStory.mock.calls).toEqual([
416
+ [mockProject, issue, 'story-regular-workflow-id'],
417
+ ]);
418
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([
419
+ [
420
+ 'https://github.com/owner/repo/issues/1',
421
+ mockProject,
422
+ 'developer-option-id',
423
+ ],
424
+ ]);
425
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([
426
+ [mockProject, issue, 'awaiting-workspace-id'],
427
+ ]);
428
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
429
+ issue,
430
+ expect.stringContaining('TRIAGER_PROPOSAL_APPROVED'),
431
+ );
432
+ });
433
+
434
+ it('should accept OK (uppercase) as an approval token', async () => {
435
+ const issue = createMockIssue({
436
+ status: 'Awaiting Workspace',
437
+ author: 'owner-user',
438
+ });
439
+ mockIssueRepository.getAllIssues.mockResolvedValue({
440
+ project: mockProject,
441
+ issues: [issue],
442
+ cacheUsed: false,
443
+ });
444
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
445
+ createComment(
446
+ 'bot',
447
+ TRIAGER_PROPOSAL_COMMENT(
448
+ 'developer',
449
+ 'regular / workflow improvement',
450
+ false,
451
+ ),
452
+ ),
453
+ createComment('owner-user', 'OK'),
454
+ ]);
455
+
456
+ await useCase.run({
457
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
458
+ allowedIssueAuthors: ['owner-user'],
459
+ });
460
+
461
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
462
+ });
463
+
464
+ it('should accept オーケー as an approval token', async () => {
465
+ const issue = createMockIssue({
466
+ status: 'Awaiting Quality Check',
467
+ author: 'owner-user',
468
+ });
469
+ mockIssueRepository.getAllIssues.mockResolvedValue({
470
+ project: mockProject,
471
+ issues: [issue],
472
+ cacheUsed: false,
473
+ });
474
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
475
+ createComment(
476
+ 'bot',
477
+ TRIAGER_PROPOSAL_COMMENT(
478
+ 'developer',
479
+ 'regular / workflow improvement',
480
+ false,
481
+ ),
482
+ ),
483
+ createComment('owner-user', 'オーケー'),
484
+ ]);
485
+
486
+ await useCase.run({
487
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
488
+ allowedIssueAuthors: ['owner-user'],
489
+ });
490
+
491
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
492
+ });
493
+
494
+ it('should not treat a long comment containing ok as an approval', async () => {
495
+ const issue = createMockIssue({
496
+ status: 'Awaiting Quality Check',
497
+ author: 'owner-user',
498
+ });
499
+ mockIssueRepository.getAllIssues.mockResolvedValue({
500
+ project: mockProject,
501
+ issues: [issue],
502
+ cacheUsed: false,
503
+ });
504
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
505
+ createComment(
506
+ 'bot',
507
+ TRIAGER_PROPOSAL_COMMENT(
508
+ 'developer',
509
+ 'regular / workflow improvement',
510
+ false,
511
+ ),
512
+ ),
513
+ createComment(
514
+ 'owner-user',
515
+ 'Looks ok but I have some concerns about the story choice.',
516
+ ),
517
+ ]);
518
+
519
+ await useCase.run({
520
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
521
+ allowedIssueAuthors: ['owner-user'],
522
+ });
523
+
524
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
525
+ });
526
+
527
+ it('should not treat an agent report comment starting with From: :robot: as an approval', async () => {
528
+ const issue = createMockIssue({
529
+ status: 'Awaiting Quality Check',
530
+ author: 'owner-user',
531
+ });
532
+ mockIssueRepository.getAllIssues.mockResolvedValue({
533
+ project: mockProject,
534
+ issues: [issue],
535
+ cacheUsed: false,
536
+ });
537
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
538
+ createComment(
539
+ 'bot',
540
+ TRIAGER_PROPOSAL_COMMENT(
541
+ 'developer',
542
+ 'regular / workflow improvement',
543
+ false,
544
+ ),
545
+ ),
546
+ createComment('owner-user', 'From: :robot: ok'),
547
+ ]);
548
+
549
+ await useCase.run({
550
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
551
+ allowedIssueAuthors: ['owner-user'],
552
+ });
553
+
554
+ expect(mockIssueRepository.updateStatus.mock.calls).toEqual([]);
555
+ });
556
+
557
+ it('should skip issues where the agent field is already set', async () => {
558
+ const issueWithAgent = createMockIssue({
559
+ status: 'Awaiting Quality Check',
560
+ author: 'owner-user',
561
+ agent: 'developer',
562
+ });
563
+ mockIssueRepository.getAllIssues.mockResolvedValue({
564
+ project: mockProject,
565
+ issues: [issueWithAgent],
566
+ cacheUsed: false,
567
+ });
568
+
569
+ await useCase.run({
570
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
571
+ allowedIssueAuthors: ['owner-user'],
572
+ });
573
+
574
+ expect(
575
+ mockIssueCommentRepository.getCommentsFromIssue.mock.calls,
576
+ ).toEqual([]);
577
+ });
578
+
579
+ it('should fetch comments for at most 20 candidate issues per cycle when the backlog is large', async () => {
580
+ const manyIssues = Array.from({ length: 25 }, (_, i) =>
581
+ createMockIssue({
582
+ number: i + 1,
583
+ url: `https://github.com/owner/repo/issues/${i + 1}`,
584
+ itemId: `item-${i + 1}`,
585
+ status: 'Awaiting Quality Check',
586
+ author: 'owner-user',
587
+ }),
588
+ );
589
+ mockIssueRepository.getAllIssues.mockResolvedValue({
590
+ project: mockProject,
591
+ issues: manyIssues,
592
+ cacheUsed: false,
593
+ });
594
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
595
+
596
+ await useCase.run({
597
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
598
+ allowedIssueAuthors: ['owner-user'],
599
+ });
600
+
601
+ expect(
602
+ mockIssueCommentRepository.getCommentsFromIssue,
603
+ ).toHaveBeenCalledTimes(20);
604
+ });
605
+
606
+ it('should dispatch an approval on a candidate beyond position 20 when the cycle index advances to the next window', async () => {
607
+ const candidates = Array.from({ length: 21 }, (_, i) =>
608
+ createMockIssue({
609
+ number: i + 1,
610
+ url: `https://github.com/owner/repo/issues/${i + 1}`,
611
+ itemId: `item-${i + 1}`,
612
+ status: 'Awaiting Quality Check',
613
+ author: 'owner-user',
614
+ createdAt: new Date(2000 + i, 0, 1),
615
+ }),
616
+ );
617
+ mockIssueRepository.getAllIssues.mockResolvedValue({
618
+ project: mockProject,
619
+ issues: candidates,
620
+ cacheUsed: false,
621
+ });
622
+ mockIssueCommentRepository.getCommentsFromIssue.mockImplementation(
623
+ async (issue) => {
624
+ if (issue.number === 21) {
625
+ return [
626
+ createComment(
627
+ 'bot',
628
+ TRIAGER_PROPOSAL_COMMENT(
629
+ 'developer',
630
+ 'regular / workflow improvement',
631
+ false,
632
+ ),
633
+ ),
634
+ createComment('owner-user', 'ok'),
635
+ ];
636
+ }
637
+ return [];
638
+ },
639
+ );
640
+
641
+ // cycleIndex=0: windowStart=0, covers positions 0-19 (issues 1-20); issue 21 not reached
642
+ const firstUseCase = new TriagerApprovalDispatchUseCase(
643
+ mockProjectRepository,
644
+ mockIssueRepository,
645
+ mockIssueCommentRepository,
646
+ );
647
+ await firstUseCase.run({
648
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
649
+ allowedIssueAuthors: ['owner-user'],
650
+ cycleIndex: 0,
651
+ });
652
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
653
+
654
+ // cycleIndex=1: windowStart=20, covers position 20 (issue 21)
655
+ const secondUseCase = new TriagerApprovalDispatchUseCase(
656
+ mockProjectRepository,
657
+ mockIssueRepository,
658
+ mockIssueCommentRepository,
659
+ );
660
+ await secondUseCase.run({
661
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
662
+ allowedIssueAuthors: ['owner-user'],
663
+ cycleIndex: 1,
664
+ });
665
+
666
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
667
+ 'https://github.com/owner/repo/issues/21',
668
+ mockProject,
669
+ 'developer-option-id',
670
+ );
671
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
672
+ });
673
+
674
+ it('should include issues where the agent field is already set to triager in the candidate list', async () => {
675
+ const issueWithTriagerAgent = createMockIssue({
676
+ status: 'Awaiting Quality Check',
677
+ author: 'owner-user',
678
+ agent: 'triager',
679
+ });
680
+ mockIssueRepository.getAllIssues.mockResolvedValue({
681
+ project: mockProject,
682
+ issues: [issueWithTriagerAgent],
683
+ cacheUsed: false,
684
+ });
685
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
686
+ createComment(
687
+ 'bot',
688
+ TRIAGER_PROPOSAL_COMMENT(
689
+ 'developer',
690
+ 'regular / workflow improvement',
691
+ false,
692
+ ),
693
+ ),
694
+ createComment('owner-user', 'ok'),
695
+ ]);
696
+
697
+ await useCase.run({
698
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
699
+ allowedIssueAuthors: ['owner-user'],
700
+ });
701
+
702
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
703
+ expect(mockIssueRepository.setIssueAgentField).toHaveBeenCalledWith(
704
+ 'https://github.com/owner/repo/issues/1',
705
+ mockProject,
706
+ 'developer-option-id',
707
+ );
708
+ });
709
+
710
+ it('should accept はい followed by natural language as an approval token', async () => {
711
+ const issue = createMockIssue({
712
+ status: 'Awaiting Quality Check',
713
+ author: 'owner-user',
714
+ });
715
+ mockIssueRepository.getAllIssues.mockResolvedValue({
716
+ project: mockProject,
717
+ issues: [issue],
718
+ cacheUsed: false,
719
+ });
720
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
721
+ createComment(
722
+ 'bot',
723
+ TRIAGER_PROPOSAL_COMMENT(
724
+ 'developer',
725
+ 'regular / workflow improvement',
726
+ false,
727
+ ),
728
+ ),
729
+ createComment('owner-user', 'はい、それで良いです。進めてください'),
730
+ ]);
731
+
732
+ await useCase.run({
733
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
734
+ allowedIssueAuthors: ['owner-user'],
735
+ });
736
+
737
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
738
+ });
739
+
740
+ it('should read all candidates across consecutive cycles even when candidate count evenly divides MAX_COMMENT_FETCHES_PER_CYCLE', async () => {
741
+ const candidates = Array.from({ length: 40 }, (_, i) =>
742
+ createMockIssue({
743
+ number: i + 1,
744
+ url: `https://github.com/owner/repo/issues/${i + 1}`,
745
+ itemId: `item-${i + 1}`,
746
+ status: 'Awaiting Quality Check',
747
+ author: 'owner-user',
748
+ createdAt: new Date(2000 + i, 0, 1),
749
+ }),
750
+ );
751
+ mockIssueRepository.getAllIssues.mockResolvedValue({
752
+ project: mockProject,
753
+ issues: candidates,
754
+ cacheUsed: false,
755
+ });
756
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
757
+
758
+ // cycleIndex=0: windowStart=0, covers positions 0-19 (issues 1-20)
759
+ const firstUseCase = new TriagerApprovalDispatchUseCase(
760
+ mockProjectRepository,
761
+ mockIssueRepository,
762
+ mockIssueCommentRepository,
763
+ );
764
+ await firstUseCase.run({
765
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
766
+ allowedIssueAuthors: ['owner-user'],
767
+ cycleIndex: 0,
768
+ });
769
+
770
+ // cycleIndex=1: windowStart=20, covers positions 20-39 (issues 21-40)
771
+ const secondUseCase = new TriagerApprovalDispatchUseCase(
772
+ mockProjectRepository,
773
+ mockIssueRepository,
774
+ mockIssueCommentRepository,
775
+ );
776
+ await secondUseCase.run({
777
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
778
+ allowedIssueAuthors: ['owner-user'],
779
+ cycleIndex: 1,
780
+ });
781
+
782
+ expect(
783
+ mockIssueCommentRepository.getCommentsFromIssue,
784
+ ).toHaveBeenCalledTimes(40);
785
+ const fetchedNumbers =
786
+ mockIssueCommentRepository.getCommentsFromIssue.mock.calls.map(
787
+ ([issue]) => issue.number,
788
+ );
789
+ for (let i = 1; i <= 40; i++) {
790
+ expect(fetchedNumbers).toContain(i);
791
+ }
792
+ });
793
+
794
+ it('should dispatch using the last proposal when the owner approved after the first of multiple proposals', async () => {
795
+ const issue = createMockIssue({
796
+ status: 'Awaiting Quality Check',
797
+ author: 'owner-user',
798
+ });
799
+ mockIssueRepository.getAllIssues.mockResolvedValue({
800
+ project: mockProject,
801
+ issues: [issue],
802
+ cacheUsed: false,
803
+ });
804
+ // p1 (storyAlreadySet=false) → owner ok → p2 (storyAlreadySet=true)
805
+ // The ok after p1 satisfies the approval gate (firstProposalIndex=0);
806
+ // the last proposal (p2, storyAlreadySet=true) is used for recommendation.
807
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
808
+ createComment(
809
+ 'bot',
810
+ TRIAGER_PROPOSAL_COMMENT(
811
+ 'developer',
812
+ 'regular / workflow improvement',
813
+ false,
814
+ ),
815
+ ),
816
+ createComment('owner-user', 'ok'),
817
+ createComment(
818
+ 'bot',
819
+ TRIAGER_PROPOSAL_COMMENT(
820
+ 'developer',
821
+ 'regular / workflow improvement',
822
+ true,
823
+ ),
824
+ ),
825
+ ]);
826
+
827
+ await useCase.run({
828
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
829
+ allowedIssueAuthors: ['owner-user'],
830
+ });
831
+
832
+ expect(mockIssueRepository.updateStory.mock.calls).toEqual([]);
833
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toHaveLength(1);
834
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
835
+ issue,
836
+ expect.stringContaining('TRIAGER_PROPOSAL_APPROVED'),
837
+ );
838
+ });
839
+
840
+ it('should use the last triager proposal when multiple proposals exist', async () => {
841
+ const issue = createMockIssue({
842
+ status: 'Awaiting Quality Check',
843
+ author: 'owner-user',
844
+ });
845
+ mockIssueRepository.getAllIssues.mockResolvedValue({
846
+ project: mockProject,
847
+ issues: [issue],
848
+ cacheUsed: false,
849
+ });
850
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
851
+ createComment(
852
+ 'bot',
853
+ TRIAGER_PROPOSAL_COMMENT(
854
+ 'developer',
855
+ 'regular / workflow improvement',
856
+ false,
857
+ ),
858
+ ),
859
+ createComment(
860
+ 'bot',
861
+ TRIAGER_PROPOSAL_COMMENT(
862
+ 'developer',
863
+ 'regular / workflow improvement',
864
+ true,
865
+ ),
866
+ ),
867
+ createComment('owner-user', 'ok'),
868
+ ]);
869
+
870
+ await useCase.run({
871
+ projectUrl: 'https://github.com/orgs/owner/projects/1',
872
+ allowedIssueAuthors: ['owner-user'],
873
+ });
874
+
875
+ expect(mockIssueRepository.updateStory.mock.calls).toEqual([]);
876
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toHaveLength(1);
877
+ });
878
+ });
879
+ });