github-issue-tower-defence-management 1.37.1 → 1.38.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 (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -0
  3. package/bin/adapter/entry-points/cli/index.js +19 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -3
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/domain/usecases/HandleScheduledEventUseCase.js +11 -1
  8. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  9. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +36 -0
  10. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -0
  11. package/bin/domain/usecases/SetNoStoryIssueToStoryUseCase.js +2 -2
  12. package/bin/domain/usecases/SetNoStoryIssueToStoryUseCase.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.ts +37 -0
  15. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +8 -0
  16. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
  17. package/src/domain/usecases/HandleScheduledEventUseCase.ts +14 -0
  18. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +302 -0
  19. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +72 -0
  20. package/src/domain/usecases/SetNoStoryIssueToStoryUseCase.test.ts +372 -0
  21. package/src/domain/usecases/SetNoStoryIssueToStoryUseCase.ts +4 -2
  22. package/types/adapter/entry-points/cli/index.d.ts +1 -0
  23. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  24. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  25. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +4 -1
  26. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  27. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +17 -0
  28. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -0
  29. package/types/domain/usecases/SetNoStoryIssueToStoryUseCase.d.ts.map +1 -1
@@ -21,6 +21,7 @@ import { AssignNoAssigneeIssueToManagerUseCase } from './AssignNoAssigneeIssueTo
21
21
  import { UpdateIssueStatusByLabelUseCase } from './UpdateIssueStatusByLabelUseCase';
22
22
  import { StartPreparationUseCase } from './StartPreparationUseCase';
23
23
  import { NotifyFinishedIssuePreparationUseCase } from './NotifyFinishedIssuePreparationUseCase';
24
+ import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
24
25
 
25
26
  export class ProjectNotFoundError extends Error {
26
27
  constructor(message: string) {
@@ -46,6 +47,7 @@ export class HandleScheduledEventUseCase {
46
47
  readonly updateIssueStatusByLabelUseCase: UpdateIssueStatusByLabelUseCase,
47
48
  readonly startPreparationUseCase: StartPreparationUseCase,
48
49
  readonly notifyFinishedIssuePreparationUseCase: NotifyFinishedIssuePreparationUseCase,
50
+ readonly revertOrphanedPreparationUseCase: RevertOrphanedPreparationUseCase,
49
51
  readonly dateRepository: DateRepository,
50
52
  readonly spreadsheetRepository: SpreadsheetRepository,
51
53
  readonly projectRepository: ProjectRepository,
@@ -73,6 +75,7 @@ export class HandleScheduledEventUseCase {
73
75
  defaultAgentName: string;
74
76
  logFilePath?: string;
75
77
  maximumPreparingIssuesCount: number | null;
78
+ preparationProcessCheckCommand?: string;
76
79
  } | null;
77
80
  notifyFinishedPreparation?: {
78
81
  preparationStatus: string;
@@ -311,6 +314,17 @@ ${JSON.stringify(e)}
311
314
  defaultStatus: input.defaultStatus,
312
315
  });
313
316
  if (input.startPreparation) {
317
+ if (input.startPreparation.preparationProcessCheckCommand) {
318
+ await this.revertOrphanedPreparationUseCase.run({
319
+ projectUrl: input.projectUrl,
320
+ preparationStatus: input.startPreparation.preparationStatus,
321
+ awaitingWorkspaceStatus:
322
+ input.startPreparation.awaitingWorkspaceStatus,
323
+ allowIssueCacheMinutes: input.allowIssueCacheMinutes,
324
+ preparationProcessCheckCommand:
325
+ input.startPreparation.preparationProcessCheckCommand,
326
+ });
327
+ }
314
328
  await this.startPreparationUseCase.run({
315
329
  projectUrl: input.projectUrl,
316
330
  awaitingWorkspaceStatus: input.startPreparation.awaitingWorkspaceStatus,
@@ -0,0 +1,302 @@
1
+ import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
+ import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
5
+ import { Issue } from '../entities/Issue';
6
+ import { Project } from '../entities/Project';
7
+
8
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
9
+
10
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
11
+ nameWithOwner: 'user/repo',
12
+ number: 1,
13
+ title: 'Test Issue',
14
+ state: 'OPEN',
15
+ status: 'Backlog',
16
+ story: 'Default Story',
17
+ nextActionDate: null,
18
+ nextActionHour: null,
19
+ estimationMinutes: null,
20
+ dependedIssueUrls: [],
21
+ completionDate50PercentConfidence: null,
22
+ url: 'https://github.com/user/repo/issues/1',
23
+ assignees: [],
24
+ labels: [],
25
+ org: 'user',
26
+ repo: 'repo',
27
+ body: '',
28
+ itemId: 'item-1',
29
+ isPr: false,
30
+ isInProgress: false,
31
+ isClosed: false,
32
+ createdAt: new Date(),
33
+ ...overrides,
34
+ });
35
+
36
+ const createMockProject = (): Project => ({
37
+ id: 'project-1',
38
+ url: 'https://github.com/orgs/user/projects/1',
39
+ databaseId: 1,
40
+ name: 'Test Project',
41
+ status: {
42
+ name: 'Status',
43
+ fieldId: 'status-field-id',
44
+ statuses: [
45
+ { id: '1', name: 'Awaiting Workspace', color: 'GRAY', description: '' },
46
+ { id: '2', name: 'Preparation', color: 'YELLOW', description: '' },
47
+ { id: '3', name: 'Done', color: 'GREEN', description: '' },
48
+ ],
49
+ },
50
+ nextActionDate: null,
51
+ nextActionHour: null,
52
+ story: {
53
+ name: 'Story',
54
+ fieldId: 'story-field-id',
55
+ databaseId: 1,
56
+ stories: [
57
+ {
58
+ id: 'story-1',
59
+ name: 'Default Story',
60
+ color: 'GRAY',
61
+ description: '',
62
+ },
63
+ ],
64
+ workflowManagementStory: {
65
+ id: 'wf-1',
66
+ name: 'Workflow Management',
67
+ },
68
+ },
69
+ remainingEstimationMinutes: null,
70
+ dependedIssueUrlSeparatedByComma: null,
71
+ completionDate50PercentConfidence: null,
72
+ });
73
+
74
+ describe('RevertOrphanedPreparationUseCase', () => {
75
+ let useCase: RevertOrphanedPreparationUseCase;
76
+ let mockProjectRepository: Mocked<
77
+ Pick<ProjectRepository, 'findProjectIdByUrl' | 'getProject'>
78
+ >;
79
+ let mockIssueRepository: Mocked<
80
+ Pick<IssueRepository, 'getAllIssues' | 'updateStatus' | 'createComment'>
81
+ >;
82
+ let mockLocalCommandRunner: Mocked<LocalCommandRunner>;
83
+ let mockProject: Project;
84
+
85
+ beforeEach(() => {
86
+ jest.resetAllMocks();
87
+ mockProject = createMockProject();
88
+ mockProjectRepository = {
89
+ findProjectIdByUrl: jest.fn().mockResolvedValue('project-1'),
90
+ getProject: jest.fn().mockResolvedValue(mockProject),
91
+ };
92
+ mockIssueRepository = {
93
+ getAllIssues: jest
94
+ .fn()
95
+ .mockResolvedValue({ issues: [], cacheUsed: false }),
96
+ updateStatus: jest.fn().mockResolvedValue(undefined),
97
+ createComment: jest.fn().mockResolvedValue(undefined),
98
+ };
99
+ mockLocalCommandRunner = {
100
+ runCommand: jest.fn(),
101
+ };
102
+ useCase = new RevertOrphanedPreparationUseCase(
103
+ mockProjectRepository,
104
+ mockIssueRepository,
105
+ mockLocalCommandRunner,
106
+ );
107
+ });
108
+
109
+ it('should revert stuck-Preparation issue to Awaiting Workspace when check command exits non-zero', async () => {
110
+ const stuckIssue = createMockIssue({
111
+ url: 'https://github.com/user/repo/issues/10',
112
+ status: 'Preparation',
113
+ });
114
+ mockIssueRepository.getAllIssues.mockResolvedValue({
115
+ issues: [stuckIssue],
116
+ cacheUsed: false,
117
+ });
118
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
119
+ stdout: '',
120
+ stderr: '',
121
+ exitCode: 1,
122
+ });
123
+
124
+ await useCase.run({
125
+ projectUrl: 'https://github.com/user/repo',
126
+ preparationStatus: 'Preparation',
127
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
128
+ allowIssueCacheMinutes: 60,
129
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
130
+ });
131
+
132
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
133
+ expect(mockIssueRepository.updateStatus.mock.calls[0][0]).toBe(mockProject);
134
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toBe(stuckIssue);
135
+ expect(mockIssueRepository.updateStatus.mock.calls[0][2]).toBe('1');
136
+ expect(mockIssueRepository.createComment.mock.calls).toHaveLength(1);
137
+ expect(mockIssueRepository.createComment.mock.calls[0][0]).toBe(stuckIssue);
138
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
139
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
140
+ 'pgrep -fa "claude-agent.*https://github.com/user/repo/issues/10"',
141
+ );
142
+ });
143
+
144
+ it('should leave in-flight Preparation issue untouched when check command exits zero', async () => {
145
+ const inFlightIssue = createMockIssue({
146
+ url: 'https://github.com/user/repo/issues/20',
147
+ status: 'Preparation',
148
+ });
149
+ mockIssueRepository.getAllIssues.mockResolvedValue({
150
+ issues: [inFlightIssue],
151
+ cacheUsed: false,
152
+ });
153
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
154
+ stdout: 'claude-agent process found',
155
+ stderr: '',
156
+ exitCode: 0,
157
+ });
158
+
159
+ await useCase.run({
160
+ projectUrl: 'https://github.com/user/repo',
161
+ preparationStatus: 'Preparation',
162
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
163
+ allowIssueCacheMinutes: 60,
164
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
165
+ });
166
+
167
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
168
+ expect(mockIssueRepository.createComment.mock.calls).toHaveLength(0);
169
+ });
170
+
171
+ it('should only process issues in Preparation status and skip others', async () => {
172
+ const preparationIssue = createMockIssue({
173
+ url: 'https://github.com/user/repo/issues/10',
174
+ status: 'Preparation',
175
+ });
176
+ const awaitingIssue = createMockIssue({
177
+ url: 'https://github.com/user/repo/issues/11',
178
+ status: 'Awaiting Workspace',
179
+ });
180
+ mockIssueRepository.getAllIssues.mockResolvedValue({
181
+ issues: [preparationIssue, awaitingIssue],
182
+ cacheUsed: false,
183
+ });
184
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
185
+ stdout: '',
186
+ stderr: '',
187
+ exitCode: 1,
188
+ });
189
+
190
+ await useCase.run({
191
+ projectUrl: 'https://github.com/user/repo',
192
+ preparationStatus: 'Preparation',
193
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
194
+ allowIssueCacheMinutes: 60,
195
+ preparationProcessCheckCommand: 'check {URL}',
196
+ });
197
+
198
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
199
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
200
+ 'check https://github.com/user/repo/issues/10',
201
+ );
202
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
203
+ });
204
+
205
+ it('should handle mixed in-flight and stuck Preparation issues correctly', async () => {
206
+ const stuckIssue = createMockIssue({
207
+ url: 'https://github.com/user/repo/issues/10',
208
+ status: 'Preparation',
209
+ });
210
+ const inFlightIssue = createMockIssue({
211
+ url: 'https://github.com/user/repo/issues/20',
212
+ status: 'Preparation',
213
+ });
214
+ mockIssueRepository.getAllIssues.mockResolvedValue({
215
+ issues: [stuckIssue, inFlightIssue],
216
+ cacheUsed: false,
217
+ });
218
+ mockLocalCommandRunner.runCommand
219
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 1 })
220
+ .mockResolvedValueOnce({
221
+ stdout: 'found',
222
+ stderr: '',
223
+ exitCode: 0,
224
+ });
225
+
226
+ await useCase.run({
227
+ projectUrl: 'https://github.com/user/repo',
228
+ preparationStatus: 'Preparation',
229
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
230
+ allowIssueCacheMinutes: 60,
231
+ preparationProcessCheckCommand: 'check {URL}',
232
+ });
233
+
234
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(1);
235
+ expect(mockIssueRepository.updateStatus.mock.calls[0][1]).toBe(stuckIssue);
236
+ expect(mockIssueRepository.createComment.mock.calls).toHaveLength(1);
237
+ });
238
+
239
+ it('should throw when project is not found by URL', async () => {
240
+ mockProjectRepository.findProjectIdByUrl.mockResolvedValue(null);
241
+
242
+ await expect(
243
+ useCase.run({
244
+ projectUrl: 'https://github.com/user/repo',
245
+ preparationStatus: 'Preparation',
246
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
247
+ allowIssueCacheMinutes: 0,
248
+ preparationProcessCheckCommand: 'check {URL}',
249
+ }),
250
+ ).rejects.toThrow('Project not found');
251
+ });
252
+
253
+ it('should do nothing when there are no Preparation issues', async () => {
254
+ mockIssueRepository.getAllIssues.mockResolvedValue({
255
+ issues: [
256
+ createMockIssue({ status: 'Awaiting Workspace' }),
257
+ createMockIssue({ status: 'Done' }),
258
+ ],
259
+ cacheUsed: false,
260
+ });
261
+
262
+ await useCase.run({
263
+ projectUrl: 'https://github.com/user/repo',
264
+ preparationStatus: 'Preparation',
265
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
266
+ allowIssueCacheMinutes: 60,
267
+ preparationProcessCheckCommand: 'check {URL}',
268
+ });
269
+
270
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(0);
271
+ expect(mockIssueRepository.updateStatus.mock.calls).toHaveLength(0);
272
+ });
273
+
274
+ it('should substitute {URL} placeholder with the issue URL in the check command', async () => {
275
+ const issue = createMockIssue({
276
+ url: 'https://github.com/org/project/issues/99',
277
+ status: 'Preparation',
278
+ });
279
+ mockIssueRepository.getAllIssues.mockResolvedValue({
280
+ issues: [issue],
281
+ cacheUsed: false,
282
+ });
283
+ mockLocalCommandRunner.runCommand.mockResolvedValue({
284
+ stdout: '',
285
+ stderr: '',
286
+ exitCode: 0,
287
+ });
288
+
289
+ await useCase.run({
290
+ projectUrl: 'https://github.com/user/repo',
291
+ preparationStatus: 'Preparation',
292
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
293
+ allowIssueCacheMinutes: 0,
294
+ preparationProcessCheckCommand: 'pgrep -fa "claude-agent.*{URL}"',
295
+ });
296
+
297
+ expect(mockLocalCommandRunner.runCommand.mock.calls).toHaveLength(1);
298
+ expect(mockLocalCommandRunner.runCommand.mock.calls[0][0]).toBe(
299
+ 'pgrep -fa "claude-agent.*https://github.com/org/project/issues/99"',
300
+ );
301
+ });
302
+ });
@@ -0,0 +1,72 @@
1
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
+ import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
4
+
5
+ export class RevertOrphanedPreparationUseCase {
6
+ constructor(
7
+ readonly projectRepository: Pick<
8
+ ProjectRepository,
9
+ 'findProjectIdByUrl' | 'getProject'
10
+ >,
11
+ readonly issueRepository: Pick<
12
+ IssueRepository,
13
+ 'getAllIssues' | 'updateStatus' | 'createComment'
14
+ >,
15
+ readonly localCommandRunner: LocalCommandRunner,
16
+ ) {}
17
+
18
+ run = async (params: {
19
+ projectUrl: string;
20
+ preparationStatus: string;
21
+ awaitingWorkspaceStatus: string;
22
+ allowIssueCacheMinutes: number;
23
+ preparationProcessCheckCommand: string;
24
+ }): Promise<void> => {
25
+ const projectId = await this.projectRepository.findProjectIdByUrl(
26
+ params.projectUrl,
27
+ );
28
+ if (!projectId) {
29
+ throw new Error(`Project not found. projectUrl: ${params.projectUrl}`);
30
+ }
31
+ const project = await this.projectRepository.getProject(projectId);
32
+ if (!project) {
33
+ throw new Error(
34
+ `Project not found. projectId: ${projectId} projectUrl: ${params.projectUrl}`,
35
+ );
36
+ }
37
+ const { issues } = await this.issueRepository.getAllIssues(
38
+ projectId,
39
+ params.allowIssueCacheMinutes,
40
+ );
41
+
42
+ const preparationIssues = issues.filter(
43
+ (issue) => issue.status === params.preparationStatus,
44
+ );
45
+
46
+ const awaitingWorkspaceStatusOption = project.status.statuses.find(
47
+ (s) => s.name === params.awaitingWorkspaceStatus,
48
+ );
49
+ if (!awaitingWorkspaceStatusOption) {
50
+ return;
51
+ }
52
+
53
+ for (const issue of preparationIssues) {
54
+ const command = params.preparationProcessCheckCommand.replace(
55
+ '{URL}',
56
+ issue.url,
57
+ );
58
+ const { exitCode } = await this.localCommandRunner.runCommand(command);
59
+ if (exitCode !== 0) {
60
+ await this.issueRepository.updateStatus(
61
+ project,
62
+ issue,
63
+ awaitingWorkspaceStatusOption.id,
64
+ );
65
+ await this.issueRepository.createComment(
66
+ issue,
67
+ `Orphaned preparation detected: no live worker process found for ${issue.url}. Status reverted to ${params.awaitingWorkspaceStatus}.`,
68
+ );
69
+ }
70
+ }
71
+ };
72
+ }