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.
- package/CHANGELOG.md +14 -0
- package/README.md +5 -0
- package/bin/adapter/entry-points/cli/index.js +19 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -3
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +30 -7
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +11 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +36 -0
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +37 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +8 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +21 -0
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +175 -3
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +73 -37
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +14 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +302 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +72 -0
- package/types/adapter/entry-points/cli/index.d.ts +1 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +4 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +17 -0
- 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 = (
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
}[] =
|
|
328
|
-
projectItems
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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,
|