github-issue-tower-defence-management 1.177.0 → 1.178.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/adapter/entry-points/console/ui-dist/assets/{index-Bh2slMtk.css → index-DeZcPNCr.css} +1 -1
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-D6aAQrf7.js → index-KCmqh781.js} +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  5. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +23 -0
  6. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  7. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +23 -7
  8. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  9. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +1 -7
  10. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  11. package/bin/domain/usecases/resolveNextStepAgentDispatchRepetition.js +31 -0
  12. package/bin/domain/usecases/resolveNextStepAgentDispatchRepetition.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.test.tsx +3 -0
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +5 -0
  16. package/src/adapter/entry-points/console/ui/src/index.css +5 -0
  17. package/src/adapter/entry-points/console/ui-dist/assets/{index-Bh2slMtk.css → index-DeZcPNCr.css} +1 -1
  18. package/src/adapter/entry-points/console/ui-dist/assets/{index-D6aAQrf7.js → index-KCmqh781.js} +1 -1
  19. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  20. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +79 -0
  21. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +37 -0
  22. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +128 -0
  23. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +43 -15
  24. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +21 -26
  25. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +5 -13
  26. package/src/domain/usecases/resolveNextStepAgentDispatchRepetition.test.ts +97 -0
  27. package/src/domain/usecases/resolveNextStepAgentDispatchRepetition.ts +49 -0
  28. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  29. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
  30. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  31. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts +0 -1
  32. package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
  33. package/types/domain/usecases/resolveNextStepAgentDispatchRepetition.d.ts +21 -0
  34. package/types/domain/usecases/resolveNextStepAgentDispatchRepetition.d.ts.map +1 -0
@@ -947,6 +947,85 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
947
947
  expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
948
948
  });
949
949
 
950
+ it('should comment the repeated dispatch when the declared nextStepAgent is already the agent field value', async () => {
951
+ const issue = createMockIssue({
952
+ url: 'https://github.com/user/repo/issues/1',
953
+ status: 'Preparation',
954
+ agent: 'accounting',
955
+ });
956
+
957
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
958
+ mockIssueRepository.get.mockResolvedValue(issue);
959
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
960
+ createMockComment({
961
+ content:
962
+ 'From: :robot: triager\n```json\n{"nextStepAgent": "accounting", "nextStep": null}\n```',
963
+ }),
964
+ ]);
965
+
966
+ await useCase.run({
967
+ projectUrl: 'https://github.com/users/user/projects/1',
968
+ issueUrl: 'https://github.com/user/repo/issues/1',
969
+ thresholdForAutoReject: 3,
970
+ workflowBlockerResolvedWebhookUrl: null,
971
+ allowedIssueAuthors: null,
972
+ });
973
+
974
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
975
+ mockProject,
976
+ expect.anything(),
977
+ 'awaiting-workspace-id',
978
+ );
979
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
980
+ expect.anything(),
981
+ expect.stringContaining('Next step agent dispatch repeated: accounting'),
982
+ );
983
+ });
984
+
985
+ it('should escalate to Failed Preparation when the declared nextStepAgent was already dispatched up to the threshold without a report', async () => {
986
+ const issue = createMockIssue({
987
+ url: 'https://github.com/user/repo/issues/1',
988
+ status: 'Preparation',
989
+ agent: 'accounting',
990
+ });
991
+
992
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
993
+ mockIssueRepository.get.mockResolvedValue(issue);
994
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
995
+ createMockComment({
996
+ content:
997
+ 'From: :robot: triager\n```json\n{"nextStepAgent": "accounting", "nextStep": null}\n```',
998
+ }),
999
+ createMockComment({
1000
+ content: 'Next step agent dispatch repeated: accounting',
1001
+ }),
1002
+ createMockComment({
1003
+ content: 'Next step agent dispatch repeated: accounting',
1004
+ }),
1005
+ ]);
1006
+
1007
+ await useCase.run({
1008
+ projectUrl: 'https://github.com/users/user/projects/1',
1009
+ issueUrl: 'https://github.com/user/repo/issues/1',
1010
+ thresholdForAutoReject: 3,
1011
+ workflowBlockerResolvedWebhookUrl: null,
1012
+ allowedIssueAuthors: null,
1013
+ });
1014
+
1015
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
1016
+ mockProject,
1017
+ expect.anything(),
1018
+ 'failed-preparation-id',
1019
+ );
1020
+ expect(mockIssueRepository.setIssueAgentField).not.toHaveBeenCalled();
1021
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1022
+ expect.anything(),
1023
+ expect.stringContaining(
1024
+ 'Failed to receive a report from the dispatched agent for 3 times',
1025
+ ),
1026
+ );
1027
+ });
1028
+
950
1029
  it('should not modify labels when nextStepAgent is specified', async () => {
951
1030
  const issue = createMockIssue({
952
1031
  url: 'https://github.com/user/repo/issues/1',
@@ -31,6 +31,7 @@ import { extractNextStepAgent } from './extractNextStepAgent';
31
31
  import { findLastAgentReport } from './findLastAgentReport';
32
32
  import { issueReactivationTriggerIsPending } from './issueReactivationTriggerIsPending';
33
33
  import { normalizeReportBody } from './normalizeReportBody';
34
+ import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
34
35
 
35
36
  export class IssueNotFoundError extends Error {
36
37
  constructor(issueUrl: string) {
@@ -229,7 +230,37 @@ export class NotifyFinishedIssuePreparationUseCase {
229
230
  const nextStepAgent = lastAgentReport
230
231
  ? extractNextStepAgent(lastAgentReport.content)
231
232
  : null;
233
+ const commentsAfterLastAgentReport = lastAgentReport
234
+ ? comments.slice(comments.indexOf(lastAgentReport) + 1)
235
+ : [];
232
236
  if (nextStepAgent !== null) {
237
+ const repetition = resolveNextStepAgentDispatchRepetition({
238
+ agentFieldValue: issue.agent,
239
+ nextStepAgent,
240
+ commentsAfterLastAgentReport,
241
+ isTrustedAuthor,
242
+ thresholdForAutoReject: params.thresholdForAutoReject,
243
+ });
244
+ if (repetition.type === 'escalateToFailedPreparation') {
245
+ issue.status = FAILED_PREPARATION_STATUS_NAME;
246
+ await this.issueRepository.update(issue, project);
247
+ await this.issueRepository.updateStatus(
248
+ project,
249
+ issue,
250
+ failedPreparationStatusOption.id,
251
+ );
252
+ await this.patchConsoleTab(issue);
253
+ await this.issueCommentRepository.createComment(
254
+ issue,
255
+ repetition.comment,
256
+ );
257
+ await this.sendWorkflowBlockerNotification(
258
+ params.issueUrl,
259
+ params.workflowBlockerResolvedWebhookUrl,
260
+ project,
261
+ );
262
+ return;
263
+ }
233
264
  const agentOptionId = await this.ensureAgentOptionAndGetId(
234
265
  project,
235
266
  nextStepAgent,
@@ -249,6 +280,12 @@ export class NotifyFinishedIssuePreparationUseCase {
249
280
  awaitingWorkspaceStatusOption.id,
250
281
  );
251
282
  await this.patchConsoleTab(issue);
283
+ if (repetition.type === 'dispatchAgain') {
284
+ await this.issueCommentRepository.createComment(
285
+ issue,
286
+ repetition.comment,
287
+ );
288
+ }
252
289
  return;
253
290
  }
254
291
 
@@ -260,6 +260,134 @@ describe('RevertOrphanedPreparationUseCase', () => {
260
260
  expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
261
261
  });
262
262
 
263
+ it('should transition to Failed Preparation when the declared next step agent is already assigned and the repetition threshold is met', async () => {
264
+ mockProject.agent = {
265
+ name: 'agent',
266
+ fieldId: 'agent-field-id',
267
+ options: [
268
+ {
269
+ id: 'agent-option-developer',
270
+ name: 'developer',
271
+ color: 'GRAY',
272
+ description: '',
273
+ },
274
+ ],
275
+ };
276
+ const stuckIssue = createMockIssue({
277
+ url: 'https://github.com/user/repo/issues/10',
278
+ status: 'Preparation',
279
+ agent: 'developer',
280
+ });
281
+ mockIssueRepository.getAllIssues.mockResolvedValue({
282
+ project: mockProject,
283
+ issues: [stuckIssue],
284
+ cacheUsed: false,
285
+ });
286
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
287
+ stdout: '',
288
+ stderr: '',
289
+ exitCode: 1,
290
+ });
291
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
292
+ {
293
+ author: 'bot',
294
+ content:
295
+ 'From: :robot: triager\n\n```json\n{"nextStep":null,"nextStepAgent":"developer"}\n```',
296
+ createdAt: new Date('2024-01-02T00:00:00Z'),
297
+ },
298
+ {
299
+ author: 'bot',
300
+ content: 'Next step agent dispatch repeated: developer',
301
+ createdAt: new Date('2024-01-02T01:00:00Z'),
302
+ },
303
+ {
304
+ author: 'bot',
305
+ content: 'Next step agent dispatch repeated: developer',
306
+ createdAt: new Date('2024-01-02T02:00:00Z'),
307
+ },
308
+ ]);
309
+
310
+ await useCase.run({
311
+ projectUrl: 'https://github.com/user/repo',
312
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
313
+ thresholdForAutoReject: 3,
314
+ allowedIssueAuthors: ['bot'],
315
+ });
316
+
317
+ expect(mockIssueRepository.setIssueAgentField.mock.calls).toEqual([]);
318
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
319
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('5');
320
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
321
+ stuckIssue,
322
+ expect.stringContaining(
323
+ 'Failed to receive a report from the dispatched agent for 3 times',
324
+ ),
325
+ );
326
+ });
327
+
328
+ it('should record the repeated dispatch even when the project has no Failed Preparation status', async () => {
329
+ mockProject.status.statuses = mockProject.status.statuses.filter(
330
+ (status) => status.name !== 'Failed Preparation',
331
+ );
332
+ mockProject.agent = {
333
+ name: 'agent',
334
+ fieldId: 'agent-field-id',
335
+ options: [
336
+ {
337
+ id: 'agent-option-developer',
338
+ name: 'developer',
339
+ color: 'GRAY',
340
+ description: '',
341
+ },
342
+ ],
343
+ };
344
+ const stuckIssue = createMockIssue({
345
+ url: 'https://github.com/user/repo/issues/10',
346
+ status: 'Preparation',
347
+ agent: 'developer',
348
+ });
349
+ mockIssueRepository.getAllIssues.mockResolvedValue({
350
+ project: mockProject,
351
+ issues: [stuckIssue],
352
+ cacheUsed: false,
353
+ });
354
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
355
+ stdout: '',
356
+ stderr: '',
357
+ exitCode: 1,
358
+ });
359
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
360
+ {
361
+ author: 'bot',
362
+ content:
363
+ 'From: :robot: triager\n\n```json\n{"nextStep":null,"nextStepAgent":"developer"}\n```',
364
+ createdAt: new Date('2024-01-02T00:00:00Z'),
365
+ },
366
+ {
367
+ author: 'bot',
368
+ content: 'Next step agent dispatch repeated: developer',
369
+ createdAt: new Date('2024-01-02T01:00:00Z'),
370
+ },
371
+ {
372
+ author: 'bot',
373
+ content: 'Next step agent dispatch repeated: developer',
374
+ createdAt: new Date('2024-01-02T02:00:00Z'),
375
+ },
376
+ ]);
377
+
378
+ await useCase.run({
379
+ projectUrl: 'https://github.com/user/repo',
380
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
381
+ thresholdForAutoReject: 3,
382
+ allowedIssueAuthors: ['bot'],
383
+ });
384
+
385
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
386
+ stuckIssue,
387
+ expect.stringContaining('Next step agent dispatch repeated: developer'),
388
+ );
389
+ });
390
+
263
391
  it('should ignore a next step agent designated by an author outside the allowed issue authors', async () => {
264
392
  mockProject.agent = {
265
393
  name: 'agent',
@@ -26,6 +26,7 @@ import { extractNextStepAgent } from './extractNextStepAgent';
26
26
  import { findLastAgentReport } from './findLastAgentReport';
27
27
  import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
28
28
  import { normalizeReportBody } from './normalizeReportBody';
29
+ import { resolveNextStepAgentDispatchRepetition } from './resolveNextStepAgentDispatchRepetition';
29
30
 
30
31
  const ORPHANED_PREPARATION_REJECTION_DETAIL = 'ORPHANED_PREPARATION';
31
32
 
@@ -121,11 +122,44 @@ export class RevertOrphanedPreparationUseCase {
121
122
  if (!isStillInPreparation) {
122
123
  continue;
123
124
  }
124
- const nextStepAgent = this.resolveNextStepAgent(
125
- comments,
126
- params.allowedIssueAuthors,
125
+ const lastAgentReport = findLastAgentReport(comments, (author) =>
126
+ isAuthorAuthorizedForAutoStatusCheck(
127
+ author,
128
+ params.allowedIssueAuthors,
129
+ ),
127
130
  );
131
+ const nextStepAgent = lastAgentReport
132
+ ? extractNextStepAgent(lastAgentReport.content)
133
+ : null;
128
134
  if (nextStepAgent !== null) {
135
+ const repetition = resolveNextStepAgentDispatchRepetition({
136
+ agentFieldValue: issue.agent,
137
+ nextStepAgent,
138
+ commentsAfterLastAgentReport: lastAgentReport
139
+ ? comments.slice(comments.indexOf(lastAgentReport) + 1)
140
+ : [],
141
+ isTrustedAuthor: (author) =>
142
+ isAuthorAuthorizedForAutoStatusCheck(
143
+ author,
144
+ params.allowedIssueAuthors,
145
+ ),
146
+ thresholdForAutoReject: params.thresholdForAutoReject,
147
+ });
148
+ if (
149
+ repetition.type === 'escalateToFailedPreparation' &&
150
+ failedPreparationStatusOption
151
+ ) {
152
+ await this.issueRepository.updateStatus(
153
+ project,
154
+ issue,
155
+ failedPreparationStatusOption.id,
156
+ );
157
+ await this.issueCommentRepository.createComment(
158
+ issue,
159
+ repetition.comment,
160
+ );
161
+ continue;
162
+ }
129
163
  const agentOptionId = await ensureAgentOptionAndGetId(
130
164
  this.projectRepository,
131
165
  project,
@@ -143,6 +177,12 @@ export class RevertOrphanedPreparationUseCase {
143
177
  issue,
144
178
  awaitingWorkspaceStatusOption.id,
145
179
  );
180
+ if (repetition.type !== 'notRepeated') {
181
+ await this.issueCommentRepository.createComment(
182
+ issue,
183
+ repetition.comment,
184
+ );
185
+ }
146
186
  continue;
147
187
  }
148
188
  if (outcome === 'returnToLabelSelectedAgent') {
@@ -346,18 +386,6 @@ export class RevertOrphanedPreparationUseCase {
346
386
  return [pr];
347
387
  };
348
388
 
349
- private resolveNextStepAgent = (
350
- comments: Comment[],
351
- allowedIssueAuthors: string[] | null | undefined,
352
- ): string | null => {
353
- const lastAgentReport = findLastAgentReport(comments, (author) =>
354
- isAuthorAuthorizedForAutoStatusCheck(author, allowedIssueAuthors),
355
- );
356
- return lastAgentReport
357
- ? extractNextStepAgent(lastAgentReport.content)
358
- : null;
359
- };
360
-
361
389
  private reportBodyHasNextStep = (body: string): boolean => {
362
390
  const reportMatch = normalizeReportBody(body).match(
363
391
  /```json\n([\s\S]*?)\n```/,
@@ -217,31 +217,27 @@ describe('GenerateConsoleListsUseCase', () => {
217
217
  expect(result['todo-by-agent'].items).toHaveLength(0);
218
218
  });
219
219
 
220
- it('selects no-story items case-insensitively for triage', () => {
220
+ it('selects all actionable items for triage regardless of story', () => {
221
221
  const result = run([
222
222
  makeIssue({ story: 'regular / NO STORY; SET STORY FIELD' }),
223
223
  makeIssue({ story: 'no story please' }),
224
224
  makeIssue({ story: 'Story Alpha' }),
225
225
  makeIssue({ story: null }),
226
226
  ]);
227
- expect(result.triage.items).toHaveLength(3);
227
+ expect(result.triage.items).toHaveLength(4);
228
228
  });
229
229
 
230
- it('keeps no-story items whose status is In Tmux by agent', () => {
230
+ it('excludes In Tmux by agent items from triage regardless of story', () => {
231
231
  const result = run([
232
232
  makeIssue({ story: 'no story', status: 'In Tmux by agent' }),
233
233
  makeIssue({ story: 'no story', status: 'in tmux by agent' }),
234
234
  makeIssue({ story: 'no story', status: 'Unread' }),
235
235
  ]);
236
236
  const statuses = result.triage.items.map((item) => item.status);
237
- expect(statuses).toEqual([
238
- 'In Tmux by agent',
239
- 'in tmux by agent',
240
- 'Unread',
241
- ]);
237
+ expect(statuses).toEqual(['Unread']);
242
238
  });
243
239
 
244
- it('keeps a no-story item that is blocked or scheduled to resume later', () => {
240
+ it('excludes items with reactivation triggers from triage', () => {
245
241
  const result = run([
246
242
  makeIssue({
247
243
  story: 'no story',
@@ -253,42 +249,42 @@ describe('GenerateConsoleListsUseCase', () => {
253
249
  }),
254
250
  makeIssue({ story: 'no story', nextActionHour: 9 }),
255
251
  ]);
256
- expect(result.triage.items).toHaveLength(3);
252
+ expect(result.triage.items).toHaveLength(0);
257
253
  });
258
254
 
259
- it('keeps a pull request and another person’s item off the triage tab', () => {
255
+ it('includes pull requests in triage when assigned, excludes unassigned and closed', () => {
260
256
  const result = run([
261
257
  makeIssue({ story: 'no story', isPr: true }),
262
258
  makeIssue({ story: 'no story', assignees: ['someone-else'] }),
263
259
  makeIssue({ story: 'no story', isClosed: true }),
264
260
  ]);
265
- expect(result.triage.items).toHaveLength(0);
261
+ expect(result.triage.items).toHaveLength(1);
266
262
  });
267
263
 
268
- it('keeps an item whose story field is empty on the triage tab', () => {
264
+ it('includes all actionable items in triage including those with a real story or null story', () => {
269
265
  const result = run([
270
266
  makeIssue({ story: null, status: 'Awaiting Workspace' }),
271
267
  makeIssue({ story: null, status: null }),
272
268
  makeIssue({ story: 'regular / NO STORY' }),
273
269
  makeIssue({ story: 'Story Alpha' }),
274
270
  ]);
275
- expect(result.triage.items).toHaveLength(3);
271
+ expect(result.triage.items).toHaveLength(4);
276
272
  });
277
273
 
278
- it('keeps a pull request, another person’s item and a closed item off the triage tab when the story field is empty', () => {
274
+ it('includes pull requests in triage, excludes unassigned and closed items with null story', () => {
279
275
  const result = run([
280
276
  makeIssue({ story: null, isPr: true }),
281
277
  makeIssue({ story: null, assignees: ['someone-else'] }),
282
278
  makeIssue({ story: null, isClosed: true }),
283
279
  ]);
284
- expect(result.triage.items).toHaveLength(0);
280
+ expect(result.triage.items).toHaveLength(1);
285
281
  });
286
282
 
287
- it('keeps every no-story item whatever status it carries', () => {
283
+ it('shows all actionable items in triage whatever status they carry, excluding In Tmux by agent', () => {
288
284
  const result = run([
289
285
  makeIssue({ story: 'no story', status: 'Unread' }),
290
286
  makeIssue({ story: 'no story', status: 'Awaiting Workspace' }),
291
- makeIssue({ story: 'no story', status: null }),
287
+ makeIssue({ story: 'no story', status: 'In Tmux by agent' }),
292
288
  makeIssue({ story: 'no story', status: 'Preparation' }),
293
289
  makeIssue({ story: 'no story', status: 'Failed Preparation' }),
294
290
  makeIssue({ story: 'no story', status: 'Awaiting Quality Check' }),
@@ -301,7 +297,6 @@ describe('GenerateConsoleListsUseCase', () => {
301
297
  expect(result.triage.items.map((item) => item.status)).toEqual([
302
298
  'Unread',
303
299
  'Awaiting Workspace',
304
- null,
305
300
  'Preparation',
306
301
  'Failed Preparation',
307
302
  'Awaiting Quality Check',
@@ -313,11 +308,11 @@ describe('GenerateConsoleListsUseCase', () => {
313
308
  ]);
314
309
  });
315
310
 
316
- it('keeps an item whose story is not a no-story name off the triage tab at every status', () => {
311
+ it('shows items with any story in triage at every status except In Tmux by agent', () => {
317
312
  const result = run([
318
313
  makeIssue({ story: 'Story Alpha', status: 'Unread' }),
319
314
  makeIssue({ story: 'Story Alpha', status: 'Awaiting Workspace' }),
320
- makeIssue({ story: 'Story Alpha', status: null }),
315
+ makeIssue({ story: 'Story Alpha', status: 'In Tmux by agent' }),
321
316
  makeIssue({ story: 'Story Alpha', status: 'Preparation' }),
322
317
  makeIssue({ story: 'Story Alpha', status: 'Failed Preparation' }),
323
318
  makeIssue({ story: 'Story Alpha', status: 'Awaiting Quality Check' }),
@@ -327,7 +322,7 @@ describe('GenerateConsoleListsUseCase', () => {
327
322
  makeIssue({ story: 'Story Alpha', status: 'Done' }),
328
323
  makeIssue({ story: 'Story Alpha', status: 'Icebox' }),
329
324
  ]);
330
- expect(result.triage.items).toHaveLength(0);
325
+ expect(result.triage.items).toHaveLength(10);
331
326
  });
332
327
 
333
328
  it('keeps a no-story item whose status is written in a different case', () => {
@@ -440,15 +435,15 @@ describe('GenerateConsoleListsUseCase', () => {
440
435
  expect(allTabItems(result)).toHaveLength(1);
441
436
  });
442
437
 
443
- it('shows a no-story In Tmux by agent issue on the triage tab and nowhere else', () => {
438
+ it('hides a no-story In Tmux by agent issue from triage and all other tabs', () => {
444
439
  const result = run([
445
440
  makeIssue({
446
441
  story: 'no story',
447
442
  status: 'In Tmux by agent',
448
443
  }),
449
444
  ]);
450
- expect(result.triage.items).toHaveLength(1);
451
- expect(allTabItems(result)).toHaveLength(1);
445
+ expect(result.triage.items).toHaveLength(0);
446
+ expect(allTabItems(result)).toHaveLength(0);
452
447
  });
453
448
 
454
449
  it('hides In Tmux by agent case-insensitively from every tab other than the workflow blocker tab', () => {
@@ -477,7 +472,7 @@ describe('GenerateConsoleListsUseCase', () => {
477
472
  expect(
478
473
  result['workflow-blocker'].items.map((item) => item.status).sort(),
479
474
  ).toEqual(['In Tmux by agent', 'In Tmux by human']);
480
- expect(result.triage.items.map((item) => item.number)).toEqual([3]);
475
+ expect(result.triage.items.map((item) => item.number)).toEqual([3, 2]);
481
476
  expect(
482
477
  result.prs.items
483
478
  .concat(result.triage.items)
@@ -219,14 +219,12 @@ export class GenerateConsoleListsUseCase {
219
219
  storyOrder,
220
220
  storyColors: this.buildStoryColorsString(storyOptions),
221
221
  items: this.sortByStoryOrder(
222
- issues
223
- .filter((issue) => this.needsStory(issue, assigneeLogin))
224
- .map((issue) =>
225
- this.projectItem(
226
- issue,
227
- relatedOpenPullRequestUrlsByIssueUrl.get(issue.url) ?? [],
228
- ),
222
+ actionableIssues.map((issue) =>
223
+ this.projectItem(
224
+ issue,
225
+ relatedOpenPullRequestUrlsByIssueUrl.get(issue.url) ?? [],
229
226
  ),
227
+ ),
230
228
  storyOrder,
231
229
  ),
232
230
  },
@@ -241,12 +239,6 @@ export class GenerateConsoleListsUseCase {
241
239
  };
242
240
  };
243
241
 
244
- private needsStory = (issue: Issue, assigneeLogin: string): boolean =>
245
- issue.isClosed === false &&
246
- issue.isPr === false &&
247
- issue.assignees.includes(assigneeLogin) &&
248
- (issue.story === null || issue.story.toLowerCase().includes('no story'));
249
-
250
242
  private isActionable = (issue: Issue, assigneeLogin: string): boolean =>
251
243
  issue.isClosed === false &&
252
244
  issue.assignees.includes(assigneeLogin) &&
@@ -0,0 +1,97 @@
1
+ import {
2
+ NEXT_STEP_AGENT_DISPATCH_REPEATED_MESSAGE_HEAD,
3
+ resolveNextStepAgentDispatchRepetition,
4
+ } from './resolveNextStepAgentDispatchRepetition';
5
+
6
+ const trustAll = (): boolean => true;
7
+
8
+ describe('resolveNextStepAgentDispatchRepetition', () => {
9
+ it('returns notRepeated when the agent field holds no value', () => {
10
+ expect(
11
+ resolveNextStepAgentDispatchRepetition({
12
+ agentFieldValue: null,
13
+ nextStepAgent: 'accounting',
14
+ commentsAfterLastAgentReport: [],
15
+ isTrustedAuthor: trustAll,
16
+ thresholdForAutoReject: 3,
17
+ }),
18
+ ).toEqual({ type: 'notRepeated' });
19
+ });
20
+
21
+ it('returns notRepeated when the agent field holds another agent', () => {
22
+ expect(
23
+ resolveNextStepAgentDispatchRepetition({
24
+ agentFieldValue: 'triager',
25
+ nextStepAgent: 'accounting',
26
+ commentsAfterLastAgentReport: [],
27
+ isTrustedAuthor: trustAll,
28
+ thresholdForAutoReject: 3,
29
+ }),
30
+ ).toEqual({ type: 'notRepeated' });
31
+ });
32
+
33
+ it('returns dispatchAgain with the attempt count when the declared agent is already assigned', () => {
34
+ const result = resolveNextStepAgentDispatchRepetition({
35
+ agentFieldValue: 'accounting',
36
+ nextStepAgent: 'accounting',
37
+ commentsAfterLastAgentReport: [],
38
+ isTrustedAuthor: trustAll,
39
+ thresholdForAutoReject: 3,
40
+ });
41
+
42
+ expect(result.type).toBe('dispatchAgain');
43
+ expect(result.type === 'dispatchAgain' ? result.comment : '').toContain(
44
+ '(1/3)',
45
+ );
46
+ });
47
+
48
+ it('matches the agent field and the declared agent ignoring case, spaces and hyphens', () => {
49
+ expect(
50
+ resolveNextStepAgentDispatchRepetition({
51
+ agentFieldValue: 'PR Reviewer',
52
+ nextStepAgent: 'pr-reviewer',
53
+ commentsAfterLastAgentReport: [],
54
+ isTrustedAuthor: trustAll,
55
+ thresholdForAutoReject: 3,
56
+ }).type,
57
+ ).toBe('dispatchAgain');
58
+ });
59
+
60
+ it('counts only repetition comments written by a trusted author', () => {
61
+ const result = resolveNextStepAgentDispatchRepetition({
62
+ agentFieldValue: 'accounting',
63
+ nextStepAgent: 'accounting',
64
+ commentsAfterLastAgentReport: [
65
+ {
66
+ author: 'stranger',
67
+ content: `${NEXT_STEP_AGENT_DISPATCH_REPEATED_MESSAGE_HEAD} accounting`,
68
+ },
69
+ {
70
+ author: 'stranger',
71
+ content: `${NEXT_STEP_AGENT_DISPATCH_REPEATED_MESSAGE_HEAD} accounting`,
72
+ },
73
+ ],
74
+ isTrustedAuthor: (author: string): boolean => author === 'bot',
75
+ thresholdForAutoReject: 3,
76
+ });
77
+
78
+ expect(result.type).toBe('dispatchAgain');
79
+ });
80
+
81
+ it('escalates once the repetition count reaches the auto reject threshold', () => {
82
+ const repeated = {
83
+ author: 'bot',
84
+ content: `${NEXT_STEP_AGENT_DISPATCH_REPEATED_MESSAGE_HEAD} accounting`,
85
+ };
86
+
87
+ const result = resolveNextStepAgentDispatchRepetition({
88
+ agentFieldValue: 'accounting',
89
+ nextStepAgent: 'accounting',
90
+ commentsAfterLastAgentReport: [repeated, repeated],
91
+ isTrustedAuthor: trustAll,
92
+ thresholdForAutoReject: 3,
93
+ });
94
+
95
+ expect(result.type).toBe('escalateToFailedPreparation');
96
+ });
97
+ });