github-issue-tower-defence-management 1.37.0 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) 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/adapter/repositories/issue/GraphqlProjectItemRepository.js +30 -7
  8. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js +11 -1
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  11. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +36 -0
  12. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -0
  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/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +21 -0
  17. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +175 -3
  18. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +73 -37
  19. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
  20. package/src/domain/usecases/HandleScheduledEventUseCase.ts +14 -0
  21. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +302 -0
  22. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +72 -0
  23. package/types/adapter/entry-points/cli/index.d.ts +1 -0
  24. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  25. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  26. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  27. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +4 -1
  28. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  29. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +17 -0
  30. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -0
@@ -18,6 +18,7 @@ import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunne
18
18
  import { OauthAPIClaudeRepository } from '../../repositories/OauthAPIClaudeRepository';
19
19
  import { GitHubIssueCommentRepository } from '../../repositories/GitHubIssueCommentRepository';
20
20
  import { FetchWebhookRepository } from '../../repositories/FetchWebhookRepository';
21
+ import { RevertOrphanedPreparationUseCase } from '../../../domain/usecases/RevertOrphanedPreparationUseCase';
21
22
  import { Project } from '../../../domain/entities/Project';
22
23
 
23
24
  type ConfigFile = {
@@ -32,6 +33,7 @@ type ConfigFile = {
32
33
  thresholdForAutoReject?: number;
33
34
  workflowBlockerResolvedWebhookUrl?: string;
34
35
  projectName?: string;
36
+ preparationProcessCheckCommand?: string;
35
37
  };
36
38
 
37
39
  type StartDaemonOptions = {
@@ -42,6 +44,7 @@ type StartDaemonOptions = {
42
44
  logFilePath?: string;
43
45
  maximumPreparingIssuesCount?: string;
44
46
  allowIssueCacheMinutes?: string;
47
+ preparationProcessCheckCommand?: string;
45
48
  configFilePath: string;
46
49
  };
47
50
 
@@ -106,6 +109,10 @@ export const loadConfigFile = (configFilePath: string): ConfigFile => {
106
109
  'workflowBlockerResolvedWebhookUrl',
107
110
  ),
108
111
  projectName: getStringValue(parsed, 'projectName'),
112
+ preparationProcessCheckCommand: getStringValue(
113
+ parsed,
114
+ 'preparationProcessCheckCommand',
115
+ ),
109
116
  };
110
117
  } catch (error) {
111
118
  const message = error instanceof Error ? error.message : String(error);
@@ -154,6 +161,10 @@ export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
154
161
  parsed,
155
162
  'workflowBlockerResolvedWebhookUrl',
156
163
  ),
164
+ preparationProcessCheckCommand: getStringValue(
165
+ parsed,
166
+ 'preparationProcessCheckCommand',
167
+ ),
157
168
  };
158
169
  } catch {
159
170
  console.warn('Failed to parse YAML from project README config section');
@@ -204,6 +215,10 @@ export const mergeConfigs = (
204
215
  cliOverrides.workflowBlockerResolvedWebhookUrl ??
205
216
  configFile.workflowBlockerResolvedWebhookUrl,
206
217
  projectName: configFile.projectName,
218
+ preparationProcessCheckCommand:
219
+ readmeOverrides.preparationProcessCheckCommand ??
220
+ cliOverrides.preparationProcessCheckCommand ??
221
+ configFile.preparationProcessCheckCommand,
207
222
  });
208
223
 
209
224
  type GraphqlProjectV2ReadmeResponse = {
@@ -355,6 +370,10 @@ program
355
370
  '--allowIssueCacheMinutes <minutes>',
356
371
  'Allow cache for issues in minutes (default: 0)',
357
372
  )
373
+ .option(
374
+ '--preparationProcessCheckCommand <template>',
375
+ 'Shell command template with {URL} placeholder to check if a preparation process is alive',
376
+ )
358
377
  .action(async (options: StartDaemonOptions) => {
359
378
  const token = process.env.GH_TOKEN;
360
379
  if (!token) {
@@ -376,6 +395,7 @@ program
376
395
  allowIssueCacheMinutes: options.allowIssueCacheMinutes
377
396
  ? Number(options.allowIssueCacheMinutes)
378
397
  : undefined,
398
+ preparationProcessCheckCommand: options.preparationProcessCheckCommand,
379
399
  };
380
400
 
381
401
  const tempProjectUrl =
@@ -484,6 +504,23 @@ program
484
504
  const claudeRepository = new OauthAPIClaudeRepository();
485
505
  const localCommandRunner = new NodeLocalCommandRunner();
486
506
 
507
+ const preparationProcessCheckCommand =
508
+ config.preparationProcessCheckCommand;
509
+ if (preparationProcessCheckCommand) {
510
+ const revertUseCase = new RevertOrphanedPreparationUseCase(
511
+ projectRepository,
512
+ issueRepository,
513
+ localCommandRunner,
514
+ );
515
+ await revertUseCase.run({
516
+ projectUrl,
517
+ preparationStatus,
518
+ awaitingWorkspaceStatus,
519
+ allowIssueCacheMinutes,
520
+ preparationProcessCheckCommand,
521
+ });
522
+ }
523
+
487
524
  const useCase = new StartPreparationUseCase(
488
525
  projectRepository,
489
526
  issueRepository,
@@ -33,6 +33,7 @@ import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparati
33
33
  import { NodeLocalCommandRunner } from '../../repositories/NodeLocalCommandRunner';
34
34
  import { StubClaudeRepository } from '../../repositories/StubClaudeRepository';
35
35
  import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
36
+ import { RevertOrphanedPreparationUseCase } from '../../../domain/usecases/RevertOrphanedPreparationUseCase';
36
37
  import { GitHubIssueCommentRepository } from '../../repositories/GitHubIssueCommentRepository';
37
38
  import { FetchWebhookRepository } from '../../repositories/FetchWebhookRepository';
38
39
 
@@ -186,6 +187,12 @@ export class HandleScheduledEventUseCaseHandler {
186
187
  issueCommentRepository,
187
188
  webhookRepository,
188
189
  );
190
+ const revertOrphanedPreparationUseCase =
191
+ new RevertOrphanedPreparationUseCase(
192
+ projectRepository,
193
+ issueRepository,
194
+ nodeLocalCommandRunner,
195
+ );
189
196
 
190
197
  const handleScheduledEventUseCase = new HandleScheduledEventUseCase(
191
198
  actionAnnouncement,
@@ -203,6 +210,7 @@ export class HandleScheduledEventUseCaseHandler {
203
210
  updateIssueStatusByLabelUseCase,
204
211
  startPreparationUseCase,
205
212
  notifyFinishedIssuePreparationUseCase,
213
+ revertOrphanedPreparationUseCase,
206
214
  systemDateRepository,
207
215
  googleSpreadsheetRepository,
208
216
  projectRepository,
@@ -177,6 +177,27 @@ describe('ApiV3CheerioRestIssueRepository', () => {
177
177
  });
178
178
  });
179
179
 
180
+ describe('getAllIssues throws when fetchProjectItems throws', () => {
181
+ it('should not write cache and should propagate error when fetchProjectItems throws', async () => {
182
+ const {
183
+ repository,
184
+ graphqlProjectItemRepository,
185
+ localStorageCacheRepository,
186
+ } = createApiV3CheerioRestIssueRepository();
187
+ const fetchError = new Error(
188
+ 'fetchProjectItems: expected 5 items but accumulated 1',
189
+ );
190
+ graphqlProjectItemRepository.fetchProjectItems.mockRejectedValue(
191
+ fetchError,
192
+ );
193
+
194
+ await expect(
195
+ repository.getAllIssues('test-project-id', 1),
196
+ ).rejects.toThrow(fetchError);
197
+ expect(localStorageCacheRepository.set).not.toHaveBeenCalled();
198
+ });
199
+ });
200
+
180
201
  describe('updateStatus', () => {
181
202
  it('should call graphqlProjectItemRepository.updateProjectField with correct parameters', async () => {
182
203
  const { repository, graphqlProjectItemRepository } =
@@ -25,12 +25,16 @@ const mockJsonResponse = <T>(data: T) => ({
25
25
  });
26
26
 
27
27
  describe('GraphqlProjectItemRepository', () => {
28
- const makePageResponse = (hasNextPage: boolean, endCursor: string) =>
28
+ const makePageResponse = (
29
+ hasNextPage: boolean,
30
+ endCursor: string,
31
+ totalCount = 2,
32
+ ) =>
29
33
  mockJsonResponse({
30
34
  data: {
31
35
  node: {
32
36
  items: {
33
- totalCount: 2,
37
+ totalCount,
34
38
  pageInfo: {
35
39
  endCursor,
36
40
  startCursor: 'cursor-start',
@@ -101,6 +105,174 @@ describe('GraphqlProjectItemRepository', () => {
101
105
  setTimeoutSpy.mockRestore();
102
106
  });
103
107
 
108
+ it('should throw when response contains GraphQL errors alongside partial data', async () => {
109
+ const localStorageRepository = new LocalStorageRepository();
110
+ const repository = new GraphqlProjectItemRepository(
111
+ localStorageRepository,
112
+ '',
113
+ 'dummy-token',
114
+ );
115
+
116
+ mockPost.mockReturnValueOnce(
117
+ mockJsonResponse({
118
+ data: {
119
+ node: {
120
+ items: {
121
+ totalCount: 1,
122
+ pageInfo: {
123
+ endCursor: 'cursor-1',
124
+ startCursor: 'cursor-start',
125
+ hasNextPage: false,
126
+ },
127
+ nodes: [
128
+ {
129
+ id: 'item-partial',
130
+ fieldValues: { nodes: [] },
131
+ content: {
132
+ repository: { nameWithOwner: 'owner/repo' },
133
+ number: 1,
134
+ title: 'Partial Issue',
135
+ state: 'OPEN',
136
+ url: 'https://github.com/owner/repo/issues/1',
137
+ body: 'body',
138
+ createdAt: '2024-01-01T00:00:00Z',
139
+ labels: { nodes: [] },
140
+ assignees: { nodes: [] },
141
+ },
142
+ },
143
+ ],
144
+ },
145
+ },
146
+ },
147
+ errors: [{ message: 'RATE_LIMITED' }],
148
+ }),
149
+ );
150
+
151
+ await expect(
152
+ repository.fetchProjectItems('test-project-id'),
153
+ ).rejects.toThrow('GitHub GraphQL errors: RATE_LIMITED');
154
+ });
155
+
156
+ it('should throw when data is null in response', async () => {
157
+ const localStorageRepository = new LocalStorageRepository();
158
+ const repository = new GraphqlProjectItemRepository(
159
+ localStorageRepository,
160
+ '',
161
+ 'dummy-token',
162
+ );
163
+
164
+ mockPost.mockReturnValueOnce(
165
+ mockJsonResponse({
166
+ data: null,
167
+ }),
168
+ );
169
+
170
+ await expect(
171
+ repository.fetchProjectItems('test-project-id'),
172
+ ).rejects.toThrow('No data returned from GitHub API');
173
+ });
174
+
175
+ it('should throw when node is null in response', async () => {
176
+ const localStorageRepository = new LocalStorageRepository();
177
+ const repository = new GraphqlProjectItemRepository(
178
+ localStorageRepository,
179
+ '',
180
+ 'dummy-token',
181
+ );
182
+
183
+ mockPost.mockReturnValueOnce(
184
+ mockJsonResponse({
185
+ data: { node: null },
186
+ }),
187
+ );
188
+
189
+ await expect(
190
+ repository.fetchProjectItems('test-project-id'),
191
+ ).rejects.toThrow('No data returned from GitHub API');
192
+ });
193
+
194
+ it('should throw when accumulated nodes count does not match totalCount', async () => {
195
+ const localStorageRepository = new LocalStorageRepository();
196
+ const repository = new GraphqlProjectItemRepository(
197
+ localStorageRepository,
198
+ '',
199
+ 'dummy-token',
200
+ );
201
+
202
+ mockPost.mockReturnValueOnce(
203
+ mockJsonResponse({
204
+ data: {
205
+ node: {
206
+ items: {
207
+ totalCount: 5,
208
+ pageInfo: {
209
+ endCursor: 'cursor-1',
210
+ startCursor: 'cursor-start',
211
+ hasNextPage: false,
212
+ },
213
+ nodes: [
214
+ {
215
+ id: 'item-1',
216
+ fieldValues: { nodes: [] },
217
+ content: {
218
+ repository: { nameWithOwner: 'owner/repo' },
219
+ number: 1,
220
+ title: 'Test Issue',
221
+ state: 'OPEN',
222
+ url: 'https://github.com/owner/repo/issues/1',
223
+ body: 'body',
224
+ createdAt: '2024-01-01T00:00:00Z',
225
+ labels: { nodes: [] },
226
+ assignees: { nodes: [] },
227
+ },
228
+ },
229
+ ],
230
+ },
231
+ },
232
+ },
233
+ }),
234
+ );
235
+
236
+ await expect(
237
+ repository.fetchProjectItems('test-project-id'),
238
+ ).rejects.toThrow(
239
+ 'fetchProjectItems: expected 5 items but accumulated 1',
240
+ );
241
+ });
242
+
243
+ it('should throw when page has no nodes but totalCount is positive', async () => {
244
+ const localStorageRepository = new LocalStorageRepository();
245
+ const repository = new GraphqlProjectItemRepository(
246
+ localStorageRepository,
247
+ '',
248
+ 'dummy-token',
249
+ );
250
+
251
+ mockPost.mockReturnValueOnce(
252
+ mockJsonResponse({
253
+ data: {
254
+ node: {
255
+ items: {
256
+ totalCount: 2,
257
+ pageInfo: {
258
+ endCursor: 'cursor-1',
259
+ startCursor: 'cursor-start',
260
+ hasNextPage: false,
261
+ },
262
+ nodes: [],
263
+ },
264
+ },
265
+ },
266
+ }),
267
+ );
268
+
269
+ await expect(
270
+ repository.fetchProjectItems('test-project-id'),
271
+ ).rejects.toThrow(
272
+ 'fetchProjectItems: expected 2 items but accumulated 0',
273
+ );
274
+ });
275
+
104
276
  it('should not sleep on first request when there is only one page', async () => {
105
277
  const localStorageRepository = new LocalStorageRepository();
106
278
  const repository = new GraphqlProjectItemRepository(
@@ -110,7 +282,7 @@ describe('GraphqlProjectItemRepository', () => {
110
282
  );
111
283
 
112
284
  const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
113
- mockPost.mockReturnValueOnce(makePageResponse(false, 'cursor-1'));
285
+ mockPost.mockReturnValueOnce(makePageResponse(false, 'cursor-1', 1));
114
286
 
115
287
  const result = await repository.fetchProjectItems('test-project-id');
116
288
 
@@ -281,17 +281,31 @@ query GetProjectItems($projectId: ID!, $after: String) {
281
281
  };
282
282
  }[];
283
283
  };
284
- };
285
- };
284
+ } | null;
285
+ } | null;
286
+ errors?: { message: string }[];
286
287
  }>();
287
- if (!response.data) {
288
+ if (response.errors && response.errors.length > 0) {
289
+ throw new Error(
290
+ `GitHub GraphQL errors: ${response.errors.map((e) => e.message).join('; ')}`,
291
+ );
292
+ }
293
+ const rawData = response.data;
294
+ if (!rawData) {
295
+ throw new Error('No data returned from GitHub API');
296
+ }
297
+ const rawNode = rawData.node;
298
+ if (rawNode === null) {
288
299
  throw new Error('No data returned from GitHub API');
289
300
  }
290
- return response.data;
301
+ return { node: rawNode };
291
302
  };
292
303
  const issues: ProjectItem[] = [];
293
304
  let after: string | null = null;
294
305
  let hasNextPage = true;
306
+ let totalCount = 0;
307
+ let cumulativeRawNodes = 0;
308
+ let pageIndex = 0;
295
309
 
296
310
  while (hasNextPage) {
297
311
  if (after !== null) {
@@ -300,6 +314,14 @@ query GetProjectItems($projectId: ID!, $after: String) {
300
314
  );
301
315
  }
302
316
  const data = await callGraphql(projectId, after);
317
+ const pageNodes = data.node.items.nodes;
318
+ const pageInfo = data.node.items.pageInfo;
319
+ totalCount = data.node.items.totalCount;
320
+ cumulativeRawNodes += pageNodes.length;
321
+ pageIndex++;
322
+ console.log(
323
+ `fetchProjectItems: page ${pageIndex}, nodes: ${pageNodes.length}, cumulative: ${cumulativeRawNodes}/${totalCount}`,
324
+ );
303
325
  const projectItems: {
304
326
  id: string;
305
327
  fieldValues: {
@@ -324,43 +346,57 @@ query GetProjectItems($projectId: ID!, $after: String) {
324
346
  labels: { nodes: { name: string }[] };
325
347
  assignees: { nodes: { login: string }[] };
326
348
  };
327
- }[] = data.node.items.nodes;
328
- projectItems
329
- // .filter(item => item.content.repository !== undefined)
330
- .forEach((item) => {
331
- if (!item || !item.content || !item.content.repository) {
332
- return;
333
- }
334
- issues.push({
335
- id: item.id,
336
- nameWithOwner: item.content.repository.nameWithOwner,
337
- number: item.content.number,
338
- title: item.content.title,
339
- state: this.convertStrToState(item.content.state),
340
- url: item.content.url,
341
- body: item.content.body,
342
- labels: item.content.labels?.nodes?.map((l) => l.name) || [],
343
- assignees: item.content.assignees?.nodes?.map((a) => a.login) || [],
344
- createdAt: item.content.createdAt || new Date().toISOString(),
345
- customFields: item.fieldValues.nodes
346
- .filter((field) => !!field.field)
347
- .map((field) => {
348
- return {
349
- name: field.field.name,
350
- value:
351
- field.name ??
352
- field.text ??
353
- field.number?.toString() ??
354
- field.date ??
355
- null,
356
- };
357
- }),
358
- });
349
+ }[] = pageNodes;
350
+ projectItems.forEach((item) => {
351
+ if (!item || !item.content || !item.content.repository) {
352
+ return;
353
+ }
354
+ issues.push({
355
+ id: item.id,
356
+ nameWithOwner: item.content.repository.nameWithOwner,
357
+ number: item.content.number,
358
+ title: item.content.title,
359
+ state: this.convertStrToState(item.content.state),
360
+ url: item.content.url,
361
+ body: item.content.body,
362
+ labels: item.content.labels?.nodes?.map((l) => l.name) || [],
363
+ assignees: item.content.assignees?.nodes?.map((a) => a.login) || [],
364
+ createdAt: item.content.createdAt || new Date().toISOString(),
365
+ customFields: item.fieldValues.nodes
366
+ .filter((field) => !!field.field)
367
+ .map((field) => {
368
+ return {
369
+ name: field.field.name,
370
+ value:
371
+ field.name ??
372
+ field.text ??
373
+ field.number?.toString() ??
374
+ field.date ??
375
+ null,
376
+ };
377
+ }),
359
378
  });
360
- const pageInfo = data.node.items.pageInfo;
379
+ });
380
+ if (
381
+ pageNodes.length === 100 &&
382
+ !pageInfo.hasNextPage &&
383
+ cumulativeRawNodes < totalCount
384
+ ) {
385
+ throw new Error(
386
+ `fetchProjectItems: page ${pageIndex} has ${pageNodes.length} nodes with hasNextPage=false but only ${cumulativeRawNodes}/${totalCount} items accumulated`,
387
+ );
388
+ }
361
389
  hasNextPage = pageInfo.hasNextPage;
362
390
  after = pageInfo.endCursor;
363
391
  }
392
+ console.log(
393
+ `fetchProjectItems: completed, totalCount: ${totalCount}, cumulativeRawNodes: ${cumulativeRawNodes}, issues: ${issues.length}`,
394
+ );
395
+ if (cumulativeRawNodes !== totalCount) {
396
+ throw new Error(
397
+ `fetchProjectItems: expected ${totalCount} items but accumulated ${cumulativeRawNodes}`,
398
+ );
399
+ }
364
400
  return issues;
365
401
  };
366
402
  getProjectItemFieldsFromIssueUrl = async (
@@ -20,6 +20,7 @@ import { AssignNoAssigneeIssueToManagerUseCase } from './AssignNoAssigneeIssueTo
20
20
  import { UpdateIssueStatusByLabelUseCase } from './UpdateIssueStatusByLabelUseCase';
21
21
  import { StartPreparationUseCase } from './StartPreparationUseCase';
22
22
  import { NotifyFinishedIssuePreparationUseCase } from './NotifyFinishedIssuePreparationUseCase';
23
+ import { RevertOrphanedPreparationUseCase } from './RevertOrphanedPreparationUseCase';
23
24
 
24
25
  describe('HandleScheduledEventUseCase', () => {
25
26
  describe('createTargetDateTimes', () => {
@@ -105,6 +106,8 @@ describe('HandleScheduledEventUseCase', () => {
105
106
  const mockStartPreparationUseCase = mock<StartPreparationUseCase>();
106
107
  const mockNotifyFinishedIssuePreparationUseCase =
107
108
  mock<NotifyFinishedIssuePreparationUseCase>();
109
+ const mockRevertOrphanedPreparationUseCase =
110
+ mock<RevertOrphanedPreparationUseCase>();
108
111
  const mockDateRepository = mock<DateRepository>();
109
112
  const mockSpreadsheetRepository = mock<SpreadsheetRepository>();
110
113
  const mockProjectRepository = mock<ProjectRepository>();
@@ -126,6 +129,7 @@ describe('HandleScheduledEventUseCase', () => {
126
129
  mockUpdateIssueStatusByLabelUseCase,
127
130
  mockStartPreparationUseCase,
128
131
  mockNotifyFinishedIssuePreparationUseCase,
132
+ mockRevertOrphanedPreparationUseCase,
129
133
  mockDateRepository,
130
134
  mockSpreadsheetRepository,
131
135
  mockProjectRepository,
@@ -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,