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.
- package/.github/workflows/console-ui.yml +1 -1
- package/.github/workflows/publish.yml +40 -7
- package/.github/workflows/test.yml +1 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/bin/adapter/entry-points/cli/index.js +10 -3
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -1
- package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js +6 -0
- package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js.map +1 -1
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +2 -11
- package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/scripts/defaultBranchTipVerify.sh +23 -0
- package/scripts/testWorkflowRunVerify.sh +47 -0
- package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
- package/src/adapter/ci/workflowRunCancellation.test.ts +86 -0
- package/src/adapter/entry-points/cli/index.test.ts +51 -6
- package/src/adapter/entry-points/cli/index.ts +12 -5
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +2 -1
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +7 -23
- package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +2 -7
- package/src/adapter/repositories/GoogleSpreadsheetRepository.integration.test.ts +10 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
- package/src/domain/usecases/ChangeStatusByStoryColorUseCase.test.ts +188 -0
- package/src/domain/usecases/ChangeStatusByStoryColorUseCase.ts +12 -0
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +27 -42
- package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +1 -10
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts +2 -0
- package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts.map +1 -1
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +0 -1
- package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
- 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(
|
|
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('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
'
|
|
113
|
+
'report the result of that step in your next output',
|
|
126
114
|
);
|
|
127
115
|
});
|
|
128
116
|
|
|
129
|
-
it('
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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('
|
|
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
|
-
|
|
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.
|
|
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
|
};
|
|
@@ -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
|
});
|