github-issue-tower-defence-management 1.148.23 → 1.148.25

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 (52) hide show
  1. package/.github/workflows/console-ui.yml +1 -1
  2. package/.github/workflows/publish.yml +40 -7
  3. package/.github/workflows/test.yml +1 -1
  4. package/.prettierignore +1 -0
  5. package/CHANGELOG.md +16 -0
  6. package/README.md +1 -1
  7. package/bin/adapter/entry-points/cli/index.js +10 -3
  8. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  9. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -1
  10. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
  12. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  13. package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js +6 -0
  14. package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js.map +1 -1
  15. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +2 -11
  16. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  17. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  18. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  19. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  20. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  21. package/package.json +1 -1
  22. package/scripts/defaultBranchTipVerify.sh +23 -0
  23. package/scripts/testWorkflowRunVerify.sh +47 -0
  24. package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
  25. package/src/adapter/ci/workflowRunCancellation.test.ts +86 -0
  26. package/src/adapter/entry-points/cli/index.test.ts +51 -6
  27. package/src/adapter/entry-points/cli/index.ts +12 -5
  28. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +2 -1
  29. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +7 -23
  30. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +2 -7
  31. package/src/adapter/repositories/GoogleSpreadsheetRepository.integration.test.ts +10 -0
  32. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
  33. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
  34. package/src/domain/usecases/ChangeStatusByStoryColorUseCase.test.ts +188 -0
  35. package/src/domain/usecases/ChangeStatusByStoryColorUseCase.ts +12 -0
  36. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +27 -42
  37. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +1 -10
  38. package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
  39. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
  40. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
  41. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
  42. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  43. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  44. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
  45. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  46. package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts +2 -0
  47. package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts.map +1 -1
  48. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +0 -1
  49. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  50. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  51. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
  52. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
@@ -10,6 +10,9 @@ describe('ChangeStatusByStoryColorUseCase', () => {
10
10
  const mockDateRepository = mock<DateRepository>();
11
11
  const mockIssueRepository = mock<IssueRepository>();
12
12
 
13
+ const manager = 'manager-user';
14
+ const nonManagerAssignee = 'human-owner';
15
+
13
16
  const mockStatus = mock<FieldOption>();
14
17
  mockStatus.id = 'status1';
15
18
  mockStatus.name = 'ToDo';
@@ -64,12 +67,14 @@ describe('ChangeStatusByStoryColorUseCase', () => {
64
67
  title: 'Issue 1',
65
68
  number: 789,
66
69
  status: 'Unread',
70
+ assignees: [],
67
71
  };
68
72
  const basicIssue2 = {
69
73
  ...mock<Issue>(),
70
74
  title: 'Issue 2',
71
75
  number: 101,
72
76
  status: 'In Progres',
77
+ assignees: [],
73
78
  };
74
79
 
75
80
  const basicStoryObject1: StoryObject = {
@@ -119,6 +124,7 @@ describe('ChangeStatusByStoryColorUseCase', () => {
119
124
  org: 'testOrg',
120
125
  repo: 'testRepo',
121
126
  storyObjectMap: basicStoryObjectMap,
127
+ manager,
122
128
  },
123
129
  expectedCalls: {
124
130
  createComment: [],
@@ -133,6 +139,7 @@ describe('ChangeStatusByStoryColorUseCase', () => {
133
139
  org: 'testOrg',
134
140
  repo: 'testRepo',
135
141
  storyObjectMap: basicStoryObjectMap,
142
+ manager,
136
143
  },
137
144
  expectedCalls: {
138
145
  createComment: [],
@@ -159,6 +166,7 @@ describe('ChangeStatusByStoryColorUseCase', () => {
159
166
  ],
160
167
  ['Story 2', basicStoryObject2],
161
168
  ]),
169
+ manager,
162
170
  },
163
171
  expectedCalls: {
164
172
  createComment: [
@@ -192,6 +200,7 @@ describe('ChangeStatusByStoryColorUseCase', () => {
192
200
  ],
193
201
  ['Story 2', basicStoryObject2],
194
202
  ]),
203
+ manager,
195
204
  },
196
205
  expectedCalls: {
197
206
  createComment: [
@@ -242,8 +251,187 @@ describe('ChangeStatusByStoryColorUseCase', () => {
242
251
  org: 'testOrg',
243
252
  repo: 'testRepo',
244
253
  storyObjectMap: basicStoryObjectMap,
254
+ manager,
245
255
  }),
246
256
  ).rejects.toThrow('First status is not found');
247
257
  });
248
258
  });
259
+
260
+ describe('first status assignment for an issue with no status', () => {
261
+ const activeStory = {
262
+ ...mock<StoryOption>(),
263
+ id: 'story1',
264
+ name: 'Story 1',
265
+ color: 'RED' as const,
266
+ };
267
+
268
+ const buildStoryObjectMap = (issue: Issue): StoryObjectMap =>
269
+ new Map([
270
+ [
271
+ 'Story 1',
272
+ {
273
+ ...basicStoryObject1,
274
+ story: activeStory,
275
+ issues: [issue],
276
+ },
277
+ ],
278
+ ]);
279
+
280
+ const runInput = (issue: Issue) => ({
281
+ project: basicProject,
282
+ cacheUsed: false,
283
+ org: 'testOrg',
284
+ repo: 'testRepo',
285
+ storyObjectMap: buildStoryObjectMap(issue),
286
+ manager,
287
+ });
288
+
289
+ it('should set the first status on an issue with no status whose only assignee is the manager', async () => {
290
+ const managerAssignedIssueWithoutStatus: Issue = {
291
+ ...basicIssue1,
292
+ status: null,
293
+ assignees: [manager],
294
+ };
295
+
296
+ await useCase.run(runInput(managerAssignedIssueWithoutStatus));
297
+
298
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
299
+ basicProject,
300
+ managerAssignedIssueWithoutStatus,
301
+ 'status1',
302
+ );
303
+ expect(mockIssueRepository.createComment).toHaveBeenCalledWith(
304
+ managerAssignedIssueWithoutStatus,
305
+ 'This issue status is changed because the story is enabled.',
306
+ );
307
+ });
308
+
309
+ it('should not set the first status on an issue with no status that is assigned to someone other than the manager', async () => {
310
+ const consoleWarnSpy = jest
311
+ .spyOn(console, 'warn')
312
+ .mockImplementation(() => undefined);
313
+ const humanAssignedIssueWithoutStatus: Issue = {
314
+ ...basicIssue1,
315
+ url: 'https://github.com/org/repo/issues/789',
316
+ status: null,
317
+ assignees: [nonManagerAssignee],
318
+ };
319
+
320
+ await useCase.run(runInput(humanAssignedIssueWithoutStatus));
321
+
322
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
323
+ expect(mockIssueRepository.createComment).not.toHaveBeenCalled();
324
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
325
+ `ChangeStatusByStoryColorUseCase: skipping the first status write because the issue has no status and is assigned to someone other than the manager. issueUrl: https://github.com/org/repo/issues/789 assignees: ${nonManagerAssignee}`,
326
+ );
327
+ consoleWarnSpy.mockRestore();
328
+ });
329
+
330
+ it('should not set the first status on an issue with no status assigned to both the manager and another person', async () => {
331
+ const consoleWarnSpy = jest
332
+ .spyOn(console, 'warn')
333
+ .mockImplementation(() => undefined);
334
+ const coAssignedIssueWithoutStatus: Issue = {
335
+ ...basicIssue1,
336
+ status: null,
337
+ assignees: [manager, nonManagerAssignee],
338
+ };
339
+
340
+ await useCase.run(runInput(coAssignedIssueWithoutStatus));
341
+
342
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
343
+ expect(mockIssueRepository.createComment).not.toHaveBeenCalled();
344
+ consoleWarnSpy.mockRestore();
345
+ });
346
+
347
+ it('should set the first status on an issue with no status that has no assignee', async () => {
348
+ const unassignedIssueWithoutStatus: Issue = {
349
+ ...basicIssue1,
350
+ status: null,
351
+ assignees: [],
352
+ };
353
+
354
+ await useCase.run(runInput(unassignedIssueWithoutStatus));
355
+
356
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
357
+ basicProject,
358
+ unassignedIssueWithoutStatus,
359
+ 'status1',
360
+ );
361
+ expect(mockIssueRepository.createComment).toHaveBeenCalledWith(
362
+ unassignedIssueWithoutStatus,
363
+ 'This issue status is changed because the story is enabled.',
364
+ );
365
+ });
366
+ });
367
+
368
+ describe('icebox exit when the story is enabled', () => {
369
+ const activeStory = {
370
+ ...mock<StoryOption>(),
371
+ id: 'story1',
372
+ name: 'Story 1',
373
+ color: 'RED' as const,
374
+ };
375
+
376
+ const buildStoryObjectMap = (issue: Issue): StoryObjectMap =>
377
+ new Map([
378
+ [
379
+ 'Story 1',
380
+ {
381
+ ...basicStoryObject1,
382
+ story: activeStory,
383
+ issues: [issue],
384
+ },
385
+ ],
386
+ ]);
387
+
388
+ const runInput = (issue: Issue) => ({
389
+ project: basicProject,
390
+ cacheUsed: false,
391
+ org: 'testOrg',
392
+ repo: 'testRepo',
393
+ storyObjectMap: buildStoryObjectMap(issue),
394
+ manager,
395
+ });
396
+
397
+ it('should move an Icebox issue that is assigned to someone other than the manager to the first status', async () => {
398
+ const assignedIceboxIssue: Issue = {
399
+ ...basicIssue1,
400
+ status: 'Icebox',
401
+ assignees: [nonManagerAssignee],
402
+ };
403
+
404
+ await useCase.run(runInput(assignedIceboxIssue));
405
+
406
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
407
+ basicProject,
408
+ assignedIceboxIssue,
409
+ 'status1',
410
+ );
411
+ expect(mockIssueRepository.createComment).toHaveBeenCalledWith(
412
+ assignedIceboxIssue,
413
+ 'This issue status is changed because the story is enabled.',
414
+ );
415
+ });
416
+
417
+ it('should move an Icebox issue that has no assignee to the first status', async () => {
418
+ const unassignedIceboxIssue: Issue = {
419
+ ...basicIssue1,
420
+ status: 'Icebox',
421
+ assignees: [],
422
+ };
423
+
424
+ await useCase.run(runInput(unassignedIceboxIssue));
425
+
426
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
427
+ basicProject,
428
+ unassignedIceboxIssue,
429
+ 'status1',
430
+ );
431
+ expect(mockIssueRepository.createComment).toHaveBeenCalledWith(
432
+ unassignedIceboxIssue,
433
+ 'This issue status is changed because the story is enabled.',
434
+ );
435
+ });
436
+ });
249
437
  });
@@ -3,6 +3,7 @@ import { Project } from '../entities/Project';
3
3
  import { DateRepository } from './adapter-interfaces/DateRepository';
4
4
  import { StoryObjectMap } from '../entities/StoryObjectMap';
5
5
  import { ICEBOX_STATUS_NAME } from '../entities/WorkflowStatus';
6
+ import { Member } from '../entities/Member';
6
7
 
7
8
  export class ChangeStatusByStoryColorUseCase {
8
9
  constructor(
@@ -19,6 +20,7 @@ export class ChangeStatusByStoryColorUseCase {
19
20
  org: string;
20
21
  repo: string;
21
22
  storyObjectMap: StoryObjectMap;
23
+ manager: Member['name'];
22
24
  }): Promise<void> => {
23
25
  const firstStatus = input.project.status.statuses[0];
24
26
  if (!firstStatus) {
@@ -52,6 +54,16 @@ export class ChangeStatusByStoryColorUseCase {
52
54
  if (issue.status && issue.status !== ICEBOX_STATUS_NAME) {
53
55
  continue;
54
56
  }
57
+ const hasNoStatus = !issue.status;
58
+ const isOwnedByNonManagerAssignee = issue.assignees.some(
59
+ (assignee) => assignee !== input.manager,
60
+ );
61
+ if (hasNoStatus && isOwnedByNonManagerAssignee) {
62
+ console.warn(
63
+ `ChangeStatusByStoryColorUseCase: skipping the first status write because the issue has no status and is assigned to someone other than the manager. issueUrl: ${issue.url} assignees: ${issue.assignees.join(', ')}`,
64
+ );
65
+ continue;
66
+ }
55
67
  await this.issueRepository.updateStatus(
56
68
  input.project,
57
69
  issue,
@@ -69,7 +69,9 @@ describe('DefaultSilentSessionMessageComposer', () => {
69
69
  ),
70
70
  ).toBe(1);
71
71
  expect(
72
- occurrences('work the owner asked for has been completed or answered'),
72
+ occurrences(
73
+ 'Resume the assigned work now by taking its next concrete step with a tool call',
74
+ ),
73
75
  ).toBe(1);
74
76
  });
75
77
 
@@ -85,66 +87,49 @@ describe('DefaultSilentSessionMessageComposer', () => {
85
87
  expect(section).toContain('No output has been observed for 10 minutes.');
86
88
  });
87
89
 
88
- it('states the owner-call format guidance exactly once, deferring to the session-documented format', () => {
89
- const section = composer.composeMainStalledSection(600);
90
- const formatOccurrences =
91
- section.split('in the format documented for this session').length - 1;
92
- expect(formatOccurrences).toBe(1);
93
- expect(section).toContain('written to be self-contained');
94
- expect(section).toContain(
95
- 'so the owner can understand the situation from that single message',
96
- );
97
- });
98
-
99
- it('explains that the owner is notified only when an owner-call is raised', () => {
90
+ it('solicits no owner-call anywhere in the main-stalled section', () => {
100
91
  const section = composer.composeMainStalledSection(600);
101
- expect(section).toContain(
92
+ const solicitingPhrases = [
93
+ 'share it through a new owner-call',
102
94
  'The owner is notified only when an owner-call is raised.',
103
- );
95
+ 'in the format documented for this session',
96
+ 're-raise',
97
+ 'This reminder is delivered only to sessions that have no registered unanswered owner-call.',
98
+ 'that call was not registered',
99
+ 'the owner has not been notified',
100
+ "a completion still awaits the owner's acknowledgment",
101
+ ];
102
+ for (const phrase of solicitingPhrases) {
103
+ expect(section).not.toContain(phrase);
104
+ }
104
105
  });
105
106
 
106
- it('frames a completed owner request as awaiting acknowledgment rather than a no-action case', () => {
107
+ it('instructs the main-stalled session to resume the assigned work with a concrete next step', () => {
107
108
  const section = composer.composeMainStalledSection(600);
108
109
  expect(section).toContain(
109
- "a completion still awaits the owner's acknowledgment",
110
+ 'Resume the assigned work now by taking its next concrete step with a tool call',
110
111
  );
111
- expect(section).toContain('so it is not a no-action case');
112
- });
113
-
114
- it('contains no marker-tag example, tag name, or angle bracket in the format guidance', () => {
115
- const section = composer.composeMainStalledSection(600);
116
- expect(section).not.toContain('marker tag');
117
- expect(section).not.toContain('opening and closing pair');
118
- expect(section).not.toContain('<');
119
- expect(section).not.toContain('>');
120
- });
121
-
122
- it('explains in the main-stalled section that the reminder reaches only sessions without a registered unanswered owner-call', () => {
123
- const section = composer.composeMainStalledSection(600);
124
112
  expect(section).toContain(
125
- 'This reminder is delivered only to sessions that have no registered unanswered owner-call.',
113
+ 'report the result of that step in your next output',
126
114
  );
127
115
  });
128
116
 
129
- it('explains in the main-stalled section that receiving the reminder while believing an owner-call is pending means the call was not registered and the owner was not notified', () => {
117
+ it('states in the main-stalled section that a period without output is not by itself a reason to contact the owner', () => {
130
118
  const section = composer.composeMainStalledSection(600);
131
119
  expect(section).toContain(
132
- 'If you believe you have already raised an owner-call and are waiting for the owner',
120
+ 'A period without output means the assigned work is not progressing, so it is not by itself a reason to contact the owner',
133
121
  );
134
122
  expect(section).toContain(
135
- 'receiving this reminder means that call was not registered',
123
+ 'please resume the work rather than sending a message about the absence of progress',
136
124
  );
137
- expect(section).toContain('the owner has not been notified');
138
125
  });
139
126
 
140
- it('instructs in the main-stalled section to review the documented owner-call format and re-raise the pending request', () => {
127
+ it('contains no marker-tag example, tag name, or angle bracket in the format guidance', () => {
141
128
  const section = composer.composeMainStalledSection(600);
142
- expect(section).toContain(
143
- 'please review the documented owner-call format for this session',
144
- );
145
- expect(section).toContain(
146
- 're-raise the pending request as a new owner-call in that format',
147
- );
129
+ expect(section).not.toContain('marker tag');
130
+ expect(section).not.toContain('opening and closing pair');
131
+ expect(section).not.toContain('<');
132
+ expect(section).not.toContain('>');
148
133
  });
149
134
 
150
135
  it('omits the self-diagnosis guidance from the stale-owner-call section', () => {
@@ -46,14 +46,6 @@ export const composeOwnerCallFormatGuidance = (): string => {
46
46
  return 'Please share it through a new owner-call in the format documented for this session, written to be self-contained so the owner can understand the situation from that single message.';
47
47
  };
48
48
 
49
- export const composeMainStalledSelfDiagnosisGuidance = (): string => {
50
- return [
51
- 'This reminder is delivered only to sessions that have no registered unanswered owner-call.',
52
- "If you believe you have already raised an owner-call and are waiting for the owner's reply, receiving this reminder means that call was not registered — its format or delivery method was incorrect — and the owner has not been notified.",
53
- 'In that case, please review the documented owner-call format for this session and re-raise the pending request as a new owner-call in that format.',
54
- ].join(' ');
55
- };
56
-
57
49
  const composeMainStalledMessage = (mainSilentSeconds: number): string => {
58
50
  const minutes = Math.floor(mainSilentSeconds / 60);
59
51
  return [
@@ -61,8 +53,7 @@ const composeMainStalledMessage = (mainSilentSeconds: number): string => {
61
53
  `1. Keep the session task list current, marking finished items as done.`,
62
54
  `2. Run independent pieces of work in parallel across sub-agents.`,
63
55
  `3. Keep a monitor in place that notices when a sub-agent has produced no output for about 5 minutes.`,
64
- `4. When an owner decision is needed, or when work the owner asked for has been completed or answered, please share it through a new owner-call — a completion still awaits the owner's acknowledgment, so it is not a no-action case. The owner is notified only when an owner-call is raised. ${composeOwnerCallFormatGuidance()}`,
65
- composeMainStalledSelfDiagnosisGuidance(),
56
+ `4. Resume the assigned work now by taking its next concrete step with a tool call, and report the result of that step in your next output. A period without output means the assigned work is not progressing, so it is not by itself a reason to contact the owner; please resume the work rather than sending a message about the absence of progress.`,
66
57
  `Please also include in your next output an estimate of the remaining minutes to finish all tasks.`,
67
58
  ].join('\n');
68
59
  };
@@ -565,6 +565,7 @@ ${JSON.stringify(e)}
565
565
  org: input.org,
566
566
  repo: input.workingReport.repo,
567
567
  storyObjectMap: storyObjectMap,
568
+ manager: input.manager,
568
569
  });
569
570
  await this.assignNoAssigneeIssueToManagerUseCase.run({
570
571
  issues,
@@ -281,6 +281,34 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
281
281
  expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
282
282
  });
283
283
 
284
+ it('should read the issue scoped to the project it was given and throw IssueNotFoundError when the issue has no item on that project', async () => {
285
+ const issueUrlOnAnotherProjectOnly =
286
+ 'https://github.com/user/repo/issues/7';
287
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
288
+ mockIssueRepository.get.mockImplementation(
289
+ async (issueUrl: string, project: Project) =>
290
+ project.id === mockProject.id
291
+ ? null
292
+ : createMockIssue({ url: issueUrl, status: 'Preparation' }),
293
+ );
294
+
295
+ await expect(
296
+ useCase.run({
297
+ projectUrl: 'https://github.com/users/user/projects/1',
298
+ issueUrl: issueUrlOnAnotherProjectOnly,
299
+ thresholdForAutoReject: 3,
300
+ workflowBlockerResolvedWebhookUrl: null,
301
+ allowedIssueAuthors: null,
302
+ }),
303
+ ).rejects.toThrow(`Issue not found: ${issueUrlOnAnotherProjectOnly}`);
304
+ expect(mockIssueRepository.get.mock.calls).toEqual([
305
+ [issueUrlOnAnotherProjectOnly, mockProject],
306
+ ]);
307
+ expect(mockIssueRepository.update).not.toHaveBeenCalled();
308
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
309
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
310
+ });
311
+
284
312
  it('should process a pull request URL the same as an issue URL when the project item resolves', async () => {
285
313
  const prIssue = createMockIssue({
286
314
  url: 'https://github.com/user/repo/pull/77',
@@ -112,6 +112,7 @@ describe('RevertOrphanedPreparationUseCase', () => {
112
112
  | 'updateStatus'
113
113
  | 'findRelatedOpenPRs'
114
114
  | 'getOpenPullRequest'
115
+ | 'get'
115
116
  >
116
117
  >;
117
118
  let mockIssueCommentRepository: Mocked<
@@ -136,6 +137,11 @@ describe('RevertOrphanedPreparationUseCase', () => {
136
137
  updateStatus: jest.fn().mockResolvedValue(undefined),
137
138
  findRelatedOpenPRs: jest.fn().mockResolvedValue([]),
138
139
  getOpenPullRequest: jest.fn().mockResolvedValue(null),
140
+ get: jest
141
+ .fn()
142
+ .mockImplementation(async (issueUrl: string) =>
143
+ createMockIssue({ url: issueUrl, status: 'Preparation' }),
144
+ ),
139
145
  };
140
146
  mockIssueCommentRepository = {
141
147
  getCommentsFromIssue: jest.fn().mockResolvedValue([]),
@@ -1424,4 +1430,210 @@ describe('RevertOrphanedPreparationUseCase', () => {
1424
1430
  'Auto Status Check: REJECTED\n- ORPHANED_PREPARATION',
1425
1431
  );
1426
1432
  });
1433
+
1434
+ describe('live status re-read before writing', () => {
1435
+ const arrangeSnapshotSaysPreparation = (
1436
+ liveStatus: string | null,
1437
+ comments: { author: string; content: string; createdAt: Date }[],
1438
+ ): Issue => {
1439
+ const snapshotIssue = createMockIssue({
1440
+ url: 'https://github.com/user/repo/issues/10',
1441
+ status: 'Preparation',
1442
+ });
1443
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1444
+ project: mockProject,
1445
+ issues: [snapshotIssue],
1446
+ cacheUsed: false,
1447
+ });
1448
+ mockIssueRepository.get.mockResolvedValue(
1449
+ liveStatus === null
1450
+ ? null
1451
+ : createMockIssue({
1452
+ url: snapshotIssue.url,
1453
+ status: liveStatus,
1454
+ }),
1455
+ );
1456
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1457
+ stdout: '',
1458
+ stderr: '',
1459
+ exitCode: 1,
1460
+ });
1461
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue(
1462
+ comments,
1463
+ );
1464
+ return snapshotIssue;
1465
+ };
1466
+
1467
+ it('does not write Failed Preparation when the live status has already moved to Awaiting Workspace', async () => {
1468
+ arrangeSnapshotSaysPreparation('Awaiting Workspace', [
1469
+ {
1470
+ author: 'bot',
1471
+ content: 'Auto Status Check: REJECTED\n- ORPHANED_PREPARATION',
1472
+ createdAt: new Date(),
1473
+ },
1474
+ {
1475
+ author: 'bot',
1476
+ content: 'Auto Status Check: REJECTED\n- ORPHANED_PREPARATION',
1477
+ createdAt: new Date(),
1478
+ },
1479
+ {
1480
+ author: 'bot',
1481
+ content:
1482
+ 'Issue has next action date or hour set: nextActionDate=null, nextActionHour=null',
1483
+ createdAt: new Date(),
1484
+ },
1485
+ ]);
1486
+
1487
+ await useCase.run({
1488
+ projectUrl: 'https://github.com/user/repo',
1489
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
1490
+ thresholdForAutoReject: 3,
1491
+ });
1492
+
1493
+ expect(
1494
+ mockIssueRepository.updateStatus.mock.calls.map((call) => call[2]),
1495
+ ).toEqual([]);
1496
+ expect(mockIssueCommentRepository.createComment.mock.calls).toHaveLength(
1497
+ 0,
1498
+ );
1499
+ expect(mockIssueRepository.get.mock.calls).toHaveLength(1);
1500
+ expect(mockIssueRepository.get.mock.calls[0][0]).toBe(
1501
+ 'https://github.com/user/repo/issues/10',
1502
+ );
1503
+ expect(mockIssueRepository.get.mock.calls[0][1]).toBe(mockProject);
1504
+ });
1505
+
1506
+ it('does not write Awaiting Quality Check when the live status has already moved to Awaiting Workspace', async () => {
1507
+ arrangeSnapshotSaysPreparation('Awaiting Workspace', [
1508
+ {
1509
+ author: 'bot',
1510
+ content: 'From: :robot: agent',
1511
+ createdAt: new Date(),
1512
+ },
1513
+ ]);
1514
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
1515
+ createPassingPr(),
1516
+ ]);
1517
+
1518
+ await useCase.run({
1519
+ projectUrl: 'https://github.com/user/repo',
1520
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
1521
+ thresholdForAutoReject: 3,
1522
+ });
1523
+
1524
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1525
+ expect(mockIssueCommentRepository.createComment.mock.calls).toHaveLength(
1526
+ 0,
1527
+ );
1528
+ });
1529
+
1530
+ it('does not write any status when the live read finds no issue', async () => {
1531
+ arrangeSnapshotSaysPreparation(null, []);
1532
+
1533
+ await useCase.run({
1534
+ projectUrl: 'https://github.com/user/repo',
1535
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
1536
+ thresholdForAutoReject: 3,
1537
+ });
1538
+
1539
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
1540
+ expect(mockIssueCommentRepository.createComment.mock.calls).toHaveLength(
1541
+ 0,
1542
+ );
1543
+ });
1544
+
1545
+ it('still writes Failed Preparation when the live status is confirmed as Preparation', async () => {
1546
+ const snapshotIssue = arrangeSnapshotSaysPreparation('Preparation', [
1547
+ {
1548
+ author: 'bot',
1549
+ content: 'Auto Status Check: REJECTED\n- ORPHANED_PREPARATION',
1550
+ createdAt: new Date(),
1551
+ },
1552
+ {
1553
+ author: 'bot',
1554
+ content: 'Auto Status Check: REJECTED\n- ORPHANED_PREPARATION',
1555
+ createdAt: new Date(),
1556
+ },
1557
+ ]);
1558
+
1559
+ await useCase.run({
1560
+ projectUrl: 'https://github.com/user/repo',
1561
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
1562
+ thresholdForAutoReject: 3,
1563
+ });
1564
+
1565
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
1566
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toBe(
1567
+ snapshotIssue,
1568
+ );
1569
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('5');
1570
+ });
1571
+
1572
+ it('logs the error, skips the candidate and keeps processing the next candidate when the live read rejects', async () => {
1573
+ const failingIssue = createMockIssue({
1574
+ url: 'https://github.com/user/repo/issues/10',
1575
+ status: 'Preparation',
1576
+ });
1577
+ const followingIssue = createMockIssue({
1578
+ url: 'https://github.com/user/repo/issues/11',
1579
+ number: 11,
1580
+ itemId: 'item-11',
1581
+ status: 'Preparation',
1582
+ });
1583
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1584
+ project: mockProject,
1585
+ issues: [failingIssue, followingIssue],
1586
+ cacheUsed: false,
1587
+ });
1588
+ const liveReadError = new Error(
1589
+ 'GitHub GraphQL API returned no data for a single project item read',
1590
+ );
1591
+ mockIssueRepository.get.mockImplementation(async (issueUrl: string) => {
1592
+ if (issueUrl === failingIssue.url) {
1593
+ throw liveReadError;
1594
+ }
1595
+ return createMockIssue({ url: issueUrl, status: 'Preparation' });
1596
+ });
1597
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
1598
+ stdout: '',
1599
+ stderr: '',
1600
+ exitCode: 1,
1601
+ });
1602
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
1603
+ const consoleErrorSpy = jest
1604
+ .spyOn(console, 'error')
1605
+ .mockImplementation(() => undefined);
1606
+
1607
+ try {
1608
+ await useCase.run({
1609
+ projectUrl: 'https://github.com/user/repo',
1610
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
1611
+ thresholdForAutoReject: 3,
1612
+ });
1613
+
1614
+ expect(
1615
+ consoleErrorSpy.mock.calls.filter(
1616
+ (call) =>
1617
+ typeof call[0] === 'string' &&
1618
+ call[0].includes(failingIssue.url) &&
1619
+ call[1] === liveReadError,
1620
+ ),
1621
+ ).toHaveLength(1);
1622
+ } finally {
1623
+ consoleErrorSpy.mockRestore();
1624
+ }
1625
+
1626
+ expect(
1627
+ mockIssueRepository.updateStatus.mock.calls.map((call) => [
1628
+ call[1].url,
1629
+ call[2],
1630
+ ]),
1631
+ ).toEqual([[followingIssue.url, '1']]);
1632
+ expect(
1633
+ mockIssueCommentRepository.createComment.mock.calls.map(
1634
+ (call) => call[0].url,
1635
+ ),
1636
+ ).toEqual([followingIssue.url]);
1637
+ });
1638
+ });
1427
1639
  });