github-issue-tower-defence-management 1.45.0 → 1.47.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 +20 -0
- package/README.md +64 -0
- package/bin/adapter/entry-points/cli/index.js +0 -52
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +0 -2
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +20 -15
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/situationFileWriter.js +98 -0
- package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -0
- package/bin/adapter/repositories/BaseGitHubRepository.js +5 -22
- package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
- package/bin/adapter/repositories/GraphqlProjectRepository.js +40 -0
- package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
- package/package.json +1 -2
- package/src/adapter/entry-points/cli/index.test.ts +0 -49
- package/src/adapter/entry-points/cli/index.ts +2 -21
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +0 -6
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +0 -2
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +23 -51
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +27 -18
- package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +417 -0
- package/src/adapter/entry-points/handlers/situationFileWriter.ts +168 -0
- package/src/adapter/repositories/BaseGitHubRepository.test.ts +3 -48
- package/src/adapter/repositories/BaseGitHubRepository.ts +5 -33
- package/src/adapter/repositories/GraphqlProjectRepository.test.ts +72 -0
- package/src/adapter/repositories/GraphqlProjectRepository.ts +57 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +30 -0
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -0
- package/types/adapter/repositories/BaseGitHubRepository.d.ts +0 -1
- package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts +5 -2
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
- package/bin/adapter/repositories/CheerioProjectRepository.js +0 -45
- package/bin/adapter/repositories/CheerioProjectRepository.js.map +0 -1
- package/src/adapter/repositories/CheerioProjectRepository.test.ts +0 -122
- package/src/adapter/repositories/CheerioProjectRepository.ts +0 -65
- package/types/adapter/repositories/CheerioProjectRepository.d.ts +0 -17
- package/types/adapter/repositories/CheerioProjectRepository.d.ts.map +0 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import type { Issue } from '../../../domain/entities/Issue';
|
|
3
|
+
import type { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
4
|
+
import { parseMeminfo, writeSituationFile } from './situationFileWriter';
|
|
5
|
+
|
|
6
|
+
jest.mock('fs');
|
|
7
|
+
|
|
8
|
+
const FIXTURE_MEMINFO = `MemTotal: 16384000 kB
|
|
9
|
+
MemFree: 4096000 kB
|
|
10
|
+
MemAvailable: 8192000 kB
|
|
11
|
+
Buffers: 512000 kB
|
|
12
|
+
Cached: 3584000 kB
|
|
13
|
+
SwapTotal: 4096000 kB
|
|
14
|
+
SwapFree: 3072000 kB
|
|
15
|
+
SwapCached: 0 kB
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const createIssue = (overrides: Partial<Issue> = {}): Issue => ({
|
|
19
|
+
nameWithOwner: 'owner/repo',
|
|
20
|
+
number: 1,
|
|
21
|
+
title: 'Test Issue',
|
|
22
|
+
state: 'OPEN',
|
|
23
|
+
status: null,
|
|
24
|
+
story: null,
|
|
25
|
+
nextActionDate: null,
|
|
26
|
+
nextActionHour: null,
|
|
27
|
+
estimationMinutes: null,
|
|
28
|
+
dependedIssueUrls: [],
|
|
29
|
+
completionDate50PercentConfidence: null,
|
|
30
|
+
url: 'https://github.com/owner/repo/issues/1',
|
|
31
|
+
assignees: [],
|
|
32
|
+
labels: [],
|
|
33
|
+
org: 'owner',
|
|
34
|
+
repo: 'repo',
|
|
35
|
+
body: '',
|
|
36
|
+
itemId: 'item-1',
|
|
37
|
+
isPr: false,
|
|
38
|
+
isInProgress: false,
|
|
39
|
+
isClosed: false,
|
|
40
|
+
createdAt: new Date('2025-01-01'),
|
|
41
|
+
author: 'user',
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const baseParams = {
|
|
46
|
+
cachePath: './tmp/cache/test-project',
|
|
47
|
+
projectId: 'PVT_kwHOtest999',
|
|
48
|
+
issues: [],
|
|
49
|
+
statusNames: {
|
|
50
|
+
awaitingQualityCheckStatus: 'Awaiting quality check',
|
|
51
|
+
preparationStatus: 'Preparation',
|
|
52
|
+
awaitingWorkspaceStatus: 'Awaiting workspace',
|
|
53
|
+
},
|
|
54
|
+
config: {
|
|
55
|
+
maximumPreparingIssuesCount: 6,
|
|
56
|
+
utilizationPercentageThreshold: 90,
|
|
57
|
+
allowIssueCacheMinutes: 5,
|
|
58
|
+
thresholdForAutoReject: 3,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
jest.clearAllMocks();
|
|
64
|
+
jest.mocked(fs.readFileSync).mockReturnValue(FIXTURE_MEMINFO);
|
|
65
|
+
jest.mocked(fs.mkdirSync).mockReturnValue(undefined);
|
|
66
|
+
jest.mocked(fs.writeFileSync).mockReturnValue(undefined);
|
|
67
|
+
jest.mocked(fs.renameSync).mockReturnValue(undefined);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('parseMeminfo', () => {
|
|
71
|
+
it('parses MemTotal, MemAvailable, SwapTotal, SwapFree from /proc/meminfo fixture', () => {
|
|
72
|
+
const result = parseMeminfo(FIXTURE_MEMINFO);
|
|
73
|
+
expect(result.memTotalKb).toBe(16384000);
|
|
74
|
+
expect(result.memAvailableKb).toBe(8192000);
|
|
75
|
+
expect(result.swapTotalKb).toBe(4096000);
|
|
76
|
+
expect(result.swapFreeKb).toBe(3072000);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns 0 for missing fields', () => {
|
|
80
|
+
const result = parseMeminfo('MemTotal: 8192000 kB\n');
|
|
81
|
+
expect(result.memAvailableKb).toBe(0);
|
|
82
|
+
expect(result.swapTotalKb).toBe(0);
|
|
83
|
+
expect(result.swapFreeKb).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('writeSituationFile', () => {
|
|
88
|
+
describe('status counts from issue fixtures', () => {
|
|
89
|
+
it('counts awaitingQualityCheckImmediatelyActionable correctly', async () => {
|
|
90
|
+
const issues = [
|
|
91
|
+
createIssue({
|
|
92
|
+
status: 'Awaiting quality check',
|
|
93
|
+
dependedIssueUrls: [],
|
|
94
|
+
nextActionDate: null,
|
|
95
|
+
nextActionHour: null,
|
|
96
|
+
}),
|
|
97
|
+
createIssue({
|
|
98
|
+
status: 'Awaiting quality check',
|
|
99
|
+
dependedIssueUrls: ['https://github.com/owner/repo/issues/2'],
|
|
100
|
+
nextActionDate: null,
|
|
101
|
+
nextActionHour: null,
|
|
102
|
+
}),
|
|
103
|
+
createIssue({
|
|
104
|
+
status: 'Awaiting quality check',
|
|
105
|
+
dependedIssueUrls: [],
|
|
106
|
+
nextActionDate: new Date('2025-06-01'),
|
|
107
|
+
nextActionHour: null,
|
|
108
|
+
}),
|
|
109
|
+
createIssue({ status: 'Awaiting quality check', nextActionHour: 9 }),
|
|
110
|
+
createIssue({ status: 'Preparation' }),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
await writeSituationFile({ ...baseParams, issues });
|
|
114
|
+
|
|
115
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
116
|
+
expect.any(String),
|
|
117
|
+
expect.stringContaining(
|
|
118
|
+
'"awaitingQualityCheckImmediatelyActionable":1',
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('counts preparation total correctly', async () => {
|
|
124
|
+
const issues = [
|
|
125
|
+
createIssue({ status: 'Preparation' }),
|
|
126
|
+
createIssue({ status: 'Preparation' }),
|
|
127
|
+
createIssue({ status: 'Awaiting workspace' }),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
await writeSituationFile({ ...baseParams, issues });
|
|
131
|
+
|
|
132
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
133
|
+
expect.any(String),
|
|
134
|
+
expect.stringContaining('"preparation":2'),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('counts awaitingWorkspaceImmediatelyActionable correctly', async () => {
|
|
139
|
+
const issues = [
|
|
140
|
+
createIssue({
|
|
141
|
+
status: 'Awaiting workspace',
|
|
142
|
+
dependedIssueUrls: [],
|
|
143
|
+
nextActionDate: null,
|
|
144
|
+
nextActionHour: null,
|
|
145
|
+
}),
|
|
146
|
+
createIssue({
|
|
147
|
+
status: 'Awaiting workspace',
|
|
148
|
+
dependedIssueUrls: [],
|
|
149
|
+
nextActionDate: null,
|
|
150
|
+
nextActionHour: null,
|
|
151
|
+
}),
|
|
152
|
+
createIssue({
|
|
153
|
+
status: 'Awaiting workspace',
|
|
154
|
+
dependedIssueUrls: ['https://github.com/owner/repo/issues/10'],
|
|
155
|
+
}),
|
|
156
|
+
createIssue({
|
|
157
|
+
status: 'Awaiting workspace',
|
|
158
|
+
dependedIssueUrls: [],
|
|
159
|
+
nextActionDate: new Date('2025-06-01'),
|
|
160
|
+
}),
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
await writeSituationFile({ ...baseParams, issues });
|
|
164
|
+
|
|
165
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
166
|
+
expect.any(String),
|
|
167
|
+
expect.stringContaining('"awaitingWorkspaceImmediatelyActionable":2'),
|
|
168
|
+
);
|
|
169
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
170
|
+
expect.any(String),
|
|
171
|
+
expect.stringContaining('"awaitingWorkspaceBlockedByDependency":1'),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('sets all counts to 0 when statusNames are null', async () => {
|
|
176
|
+
const issues = [
|
|
177
|
+
createIssue({ status: 'Preparation' }),
|
|
178
|
+
createIssue({ status: 'Awaiting workspace' }),
|
|
179
|
+
];
|
|
180
|
+
const params = {
|
|
181
|
+
...baseParams,
|
|
182
|
+
statusNames: {
|
|
183
|
+
awaitingQualityCheckStatus: null,
|
|
184
|
+
preparationStatus: null,
|
|
185
|
+
awaitingWorkspaceStatus: null,
|
|
186
|
+
},
|
|
187
|
+
issues,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
await writeSituationFile(params);
|
|
191
|
+
|
|
192
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
193
|
+
expect.any(String),
|
|
194
|
+
expect.stringContaining(
|
|
195
|
+
'"awaitingQualityCheckImmediatelyActionable":0',
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
199
|
+
expect.any(String),
|
|
200
|
+
expect.stringContaining('"preparation":0'),
|
|
201
|
+
);
|
|
202
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
203
|
+
expect.any(String),
|
|
204
|
+
expect.stringContaining('"awaitingWorkspaceImmediatelyActionable":0'),
|
|
205
|
+
);
|
|
206
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
207
|
+
expect.any(String),
|
|
208
|
+
expect.stringContaining('"awaitingWorkspaceBlockedByDependency":0'),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('system metrics from /proc/meminfo fixture', () => {
|
|
214
|
+
it('parses memory usedPercent from fixture', async () => {
|
|
215
|
+
await writeSituationFile(baseParams);
|
|
216
|
+
|
|
217
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
218
|
+
expect.any(String),
|
|
219
|
+
expect.stringContaining('"usedPercent":50'),
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('parses swap usedPercent from fixture', async () => {
|
|
224
|
+
await writeSituationFile(baseParams);
|
|
225
|
+
|
|
226
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
227
|
+
expect.any(String),
|
|
228
|
+
expect.stringContaining('"usedPercent":25'),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('includes memory totalGib from fixture', async () => {
|
|
233
|
+
await writeSituationFile(baseParams);
|
|
234
|
+
|
|
235
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
236
|
+
expect.any(String),
|
|
237
|
+
expect.stringContaining('"system"'),
|
|
238
|
+
);
|
|
239
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
240
|
+
expect.any(String),
|
|
241
|
+
expect.stringContaining('"memory"'),
|
|
242
|
+
);
|
|
243
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
244
|
+
expect.any(String),
|
|
245
|
+
expect.stringContaining('"swap"'),
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('handles zero swap total without division by zero', async () => {
|
|
250
|
+
jest
|
|
251
|
+
.mocked(fs.readFileSync)
|
|
252
|
+
.mockReturnValue(
|
|
253
|
+
'MemTotal: 8192000 kB\nMemAvailable: 4096000 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB\n',
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await writeSituationFile(baseParams);
|
|
257
|
+
|
|
258
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
259
|
+
expect.any(String),
|
|
260
|
+
expect.stringContaining(
|
|
261
|
+
'"swap":{"usedPercent":0,"usedGib":0,"totalGib":0}',
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('atomic write path', () => {
|
|
268
|
+
it('writes to tmp file then renames to final path', async () => {
|
|
269
|
+
await writeSituationFile(baseParams);
|
|
270
|
+
|
|
271
|
+
const expectedFinalPath = `${baseParams.cachePath}/situation-${baseParams.projectId}.json`;
|
|
272
|
+
const expectedTmpPath = `${expectedFinalPath}.tmp`;
|
|
273
|
+
|
|
274
|
+
expect(jest.mocked(fs.mkdirSync)).toHaveBeenCalledWith(
|
|
275
|
+
baseParams.cachePath,
|
|
276
|
+
{ recursive: true },
|
|
277
|
+
);
|
|
278
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
279
|
+
expectedTmpPath,
|
|
280
|
+
expect.any(String),
|
|
281
|
+
);
|
|
282
|
+
expect(jest.mocked(fs.renameSync)).toHaveBeenCalledWith(
|
|
283
|
+
expectedTmpPath,
|
|
284
|
+
expectedFinalPath,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('writes mkdirSync before writeFileSync before renameSync', async () => {
|
|
289
|
+
const callOrder: string[] = [];
|
|
290
|
+
jest.mocked(fs.mkdirSync).mockImplementation((): undefined => {
|
|
291
|
+
callOrder.push('mkdir');
|
|
292
|
+
return undefined;
|
|
293
|
+
});
|
|
294
|
+
jest.mocked(fs.writeFileSync).mockImplementation((): void => {
|
|
295
|
+
callOrder.push('write');
|
|
296
|
+
});
|
|
297
|
+
jest.mocked(fs.renameSync).mockImplementation((): void => {
|
|
298
|
+
callOrder.push('rename');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await writeSituationFile(baseParams);
|
|
302
|
+
|
|
303
|
+
expect(callOrder).toEqual(['mkdir', 'write', 'rename']);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('written JSON contains config and capturedAt fields', async () => {
|
|
307
|
+
await writeSituationFile(baseParams);
|
|
308
|
+
|
|
309
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
310
|
+
expect.any(String),
|
|
311
|
+
expect.stringContaining('"capturedAt"'),
|
|
312
|
+
);
|
|
313
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
314
|
+
expect.any(String),
|
|
315
|
+
expect.stringContaining('"maximumPreparingIssuesCount":6'),
|
|
316
|
+
);
|
|
317
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
318
|
+
expect.any(String),
|
|
319
|
+
expect.stringContaining('"utilizationPercentageThreshold":90'),
|
|
320
|
+
);
|
|
321
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
322
|
+
expect.any(String),
|
|
323
|
+
expect.stringContaining('"allowIssueCacheMinutes":5'),
|
|
324
|
+
);
|
|
325
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
326
|
+
expect.any(String),
|
|
327
|
+
expect.stringContaining('"thresholdForAutoReject":3'),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('running preparation processes', () => {
|
|
333
|
+
it('counts running processes via preparationProcessCheckCommand', async () => {
|
|
334
|
+
const mockRunCommand = jest
|
|
335
|
+
.fn()
|
|
336
|
+
.mockImplementation(async (_program: string, args: string[]) =>
|
|
337
|
+
Promise.resolve({
|
|
338
|
+
stdout: '',
|
|
339
|
+
stderr: '',
|
|
340
|
+
exitCode:
|
|
341
|
+
args[3] === 'https://github.com/owner/repo/issues/1' ? 0 : 1,
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
const mockRunner: LocalCommandRunner = {
|
|
345
|
+
runCommand: mockRunCommand,
|
|
346
|
+
};
|
|
347
|
+
const issues = [
|
|
348
|
+
createIssue({
|
|
349
|
+
status: 'Preparation',
|
|
350
|
+
url: 'https://github.com/owner/repo/issues/1',
|
|
351
|
+
number: 1,
|
|
352
|
+
}),
|
|
353
|
+
createIssue({
|
|
354
|
+
status: 'Preparation',
|
|
355
|
+
url: 'https://github.com/owner/repo/issues/2',
|
|
356
|
+
number: 2,
|
|
357
|
+
}),
|
|
358
|
+
];
|
|
359
|
+
|
|
360
|
+
await writeSituationFile({
|
|
361
|
+
...baseParams,
|
|
362
|
+
issues,
|
|
363
|
+
preparationProcessCheckCommand: 'pgrep -fa "{URL}"',
|
|
364
|
+
localCommandRunner: mockRunner,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
368
|
+
expect.any(String),
|
|
369
|
+
expect.stringContaining('"runningPreparation":1'),
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('sets runningPreparation to 0 when no preparation issues exist', async () => {
|
|
374
|
+
const mockRunCommand = jest.fn();
|
|
375
|
+
const mockRunner: LocalCommandRunner = {
|
|
376
|
+
runCommand: mockRunCommand,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
await writeSituationFile({
|
|
380
|
+
...baseParams,
|
|
381
|
+
issues: [],
|
|
382
|
+
preparationProcessCheckCommand: 'pgrep -fa "{URL}"',
|
|
383
|
+
localCommandRunner: mockRunner,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
387
|
+
expect.any(String),
|
|
388
|
+
expect.stringContaining('"runningPreparation":0'),
|
|
389
|
+
);
|
|
390
|
+
expect(mockRunCommand).not.toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('sets runningPreparation to null when no localCommandRunner provided', async () => {
|
|
394
|
+
await writeSituationFile({
|
|
395
|
+
...baseParams,
|
|
396
|
+
preparationProcessCheckCommand: 'pgrep -fa "{URL}"',
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
400
|
+
expect.any(String),
|
|
401
|
+
expect.stringContaining('"runningPreparation":null'),
|
|
402
|
+
);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('sets runningPreparation to null when no preparationProcessCheckCommand', async () => {
|
|
406
|
+
await writeSituationFile({
|
|
407
|
+
...baseParams,
|
|
408
|
+
preparationProcessCheckCommand: null,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
412
|
+
expect.any(String),
|
|
413
|
+
expect.stringContaining('"runningPreparation":null'),
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import type { Issue } from '../../../domain/entities/Issue';
|
|
3
|
+
import type { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
4
|
+
|
|
5
|
+
export type SituationFileParams = {
|
|
6
|
+
cachePath: string;
|
|
7
|
+
projectId: string;
|
|
8
|
+
issues: Issue[];
|
|
9
|
+
statusNames: {
|
|
10
|
+
awaitingQualityCheckStatus: string | null;
|
|
11
|
+
preparationStatus: string | null;
|
|
12
|
+
awaitingWorkspaceStatus: string | null;
|
|
13
|
+
};
|
|
14
|
+
config: {
|
|
15
|
+
maximumPreparingIssuesCount: number | null;
|
|
16
|
+
utilizationPercentageThreshold: number;
|
|
17
|
+
allowIssueCacheMinutes: number;
|
|
18
|
+
thresholdForAutoReject: number;
|
|
19
|
+
};
|
|
20
|
+
preparationProcessCheckCommand?: string | null;
|
|
21
|
+
localCommandRunner?: LocalCommandRunner;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type MeminfoValues = {
|
|
25
|
+
memTotalKb: number;
|
|
26
|
+
memAvailableKb: number;
|
|
27
|
+
swapTotalKb: number;
|
|
28
|
+
swapFreeKb: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const parseMeminfo = (meminfo: string): MeminfoValues => {
|
|
32
|
+
const getValue = (key: string): number => {
|
|
33
|
+
const match = meminfo.match(new RegExp(`^${key}:\\s+(\\d+)`, 'm'));
|
|
34
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
memTotalKb: getValue('MemTotal'),
|
|
38
|
+
memAvailableKb: getValue('MemAvailable'),
|
|
39
|
+
swapTotalKb: getValue('SwapTotal'),
|
|
40
|
+
swapFreeKb: getValue('SwapFree'),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const kbToGib = (kb: number): number =>
|
|
45
|
+
Math.round((kb / 1024 / 1024) * 100) / 100;
|
|
46
|
+
|
|
47
|
+
const toPercent = (used: number, total: number): number =>
|
|
48
|
+
total > 0 ? Math.round((used / total) * 1000) / 10 : 0;
|
|
49
|
+
|
|
50
|
+
const isImmediatelyActionable = (issue: Issue): boolean =>
|
|
51
|
+
issue.dependedIssueUrls.length === 0 &&
|
|
52
|
+
issue.nextActionDate === null &&
|
|
53
|
+
issue.nextActionHour === null;
|
|
54
|
+
|
|
55
|
+
const countRunningProcesses = async (
|
|
56
|
+
preparationIssues: Issue[],
|
|
57
|
+
commandTemplate: string,
|
|
58
|
+
localCommandRunner: LocalCommandRunner,
|
|
59
|
+
): Promise<number> => {
|
|
60
|
+
const resolvedTemplate = commandTemplate.replace('{URL}', '$1');
|
|
61
|
+
const checks = await Promise.all(
|
|
62
|
+
preparationIssues.map(async (issue) => {
|
|
63
|
+
const { exitCode } = await localCommandRunner.runCommand('sh', [
|
|
64
|
+
'-c',
|
|
65
|
+
resolvedTemplate,
|
|
66
|
+
'--',
|
|
67
|
+
issue.url,
|
|
68
|
+
]);
|
|
69
|
+
return exitCode === 0;
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
return checks.filter(Boolean).length;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const writeSituationFile = async (
|
|
76
|
+
params: SituationFileParams,
|
|
77
|
+
): Promise<void> => {
|
|
78
|
+
const {
|
|
79
|
+
cachePath,
|
|
80
|
+
projectId,
|
|
81
|
+
issues,
|
|
82
|
+
statusNames,
|
|
83
|
+
config,
|
|
84
|
+
preparationProcessCheckCommand,
|
|
85
|
+
localCommandRunner,
|
|
86
|
+
} = params;
|
|
87
|
+
|
|
88
|
+
const awaitingQualityCheckImmediatelyActionable =
|
|
89
|
+
statusNames.awaitingQualityCheckStatus !== null
|
|
90
|
+
? issues.filter(
|
|
91
|
+
(i) =>
|
|
92
|
+
i.status === statusNames.awaitingQualityCheckStatus &&
|
|
93
|
+
isImmediatelyActionable(i),
|
|
94
|
+
).length
|
|
95
|
+
: 0;
|
|
96
|
+
|
|
97
|
+
const preparationIssues =
|
|
98
|
+
statusNames.preparationStatus !== null
|
|
99
|
+
? issues.filter((i) => i.status === statusNames.preparationStatus)
|
|
100
|
+
: [];
|
|
101
|
+
|
|
102
|
+
const awaitingWorkspaceIssues =
|
|
103
|
+
statusNames.awaitingWorkspaceStatus !== null
|
|
104
|
+
? issues.filter((i) => i.status === statusNames.awaitingWorkspaceStatus)
|
|
105
|
+
: [];
|
|
106
|
+
|
|
107
|
+
const awaitingWorkspaceImmediatelyActionable = awaitingWorkspaceIssues.filter(
|
|
108
|
+
isImmediatelyActionable,
|
|
109
|
+
).length;
|
|
110
|
+
|
|
111
|
+
const awaitingWorkspaceBlockedByDependency = awaitingWorkspaceIssues.filter(
|
|
112
|
+
(i) => i.dependedIssueUrls.length > 0,
|
|
113
|
+
).length;
|
|
114
|
+
|
|
115
|
+
let runningPreparation: number | null = null;
|
|
116
|
+
if (
|
|
117
|
+
preparationProcessCheckCommand &&
|
|
118
|
+
localCommandRunner &&
|
|
119
|
+
preparationIssues.length > 0
|
|
120
|
+
) {
|
|
121
|
+
runningPreparation = await countRunningProcesses(
|
|
122
|
+
preparationIssues,
|
|
123
|
+
preparationProcessCheckCommand,
|
|
124
|
+
localCommandRunner,
|
|
125
|
+
);
|
|
126
|
+
} else if (preparationProcessCheckCommand && localCommandRunner) {
|
|
127
|
+
runningPreparation = 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const meminfo = fs.readFileSync('/proc/meminfo', 'utf-8');
|
|
131
|
+
const { memTotalKb, memAvailableKb, swapTotalKb, swapFreeKb } =
|
|
132
|
+
parseMeminfo(meminfo);
|
|
133
|
+
|
|
134
|
+
const memUsedKb = memTotalKb - memAvailableKb;
|
|
135
|
+
const swapUsedKb = swapTotalKb - swapFreeKb;
|
|
136
|
+
|
|
137
|
+
const situation = {
|
|
138
|
+
capturedAt: new Date().toISOString(),
|
|
139
|
+
config,
|
|
140
|
+
status: {
|
|
141
|
+
awaitingQualityCheckImmediatelyActionable,
|
|
142
|
+
preparation: preparationIssues.length,
|
|
143
|
+
awaitingWorkspaceImmediatelyActionable,
|
|
144
|
+
awaitingWorkspaceBlockedByDependency,
|
|
145
|
+
},
|
|
146
|
+
processes: {
|
|
147
|
+
runningPreparation,
|
|
148
|
+
},
|
|
149
|
+
system: {
|
|
150
|
+
memory: {
|
|
151
|
+
usedPercent: toPercent(memUsedKb, memTotalKb),
|
|
152
|
+
usedGib: kbToGib(memUsedKb),
|
|
153
|
+
totalGib: kbToGib(memTotalKb),
|
|
154
|
+
},
|
|
155
|
+
swap: {
|
|
156
|
+
usedPercent: toPercent(swapUsedKb, swapTotalKb),
|
|
157
|
+
usedGib: kbToGib(swapUsedKb),
|
|
158
|
+
totalGib: kbToGib(swapTotalKb),
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const finalPath = `${cachePath}/situation-${projectId}.json`;
|
|
164
|
+
const tmpPath = `${finalPath}.tmp`;
|
|
165
|
+
fs.mkdirSync(cachePath, { recursive: true });
|
|
166
|
+
fs.writeFileSync(tmpPath, JSON.stringify(situation));
|
|
167
|
+
fs.renameSync(tmpPath, finalPath);
|
|
168
|
+
};
|
|
@@ -19,12 +19,6 @@ import fs from 'fs';
|
|
|
19
19
|
import { BaseGitHubRepository } from './BaseGitHubRepository';
|
|
20
20
|
import resetAllMocks = jest.resetAllMocks;
|
|
21
21
|
import { LocalStorageRepository } from './LocalStorageRepository';
|
|
22
|
-
|
|
23
|
-
const mockGetCookieContent = jest.fn<Promise<unknown>, unknown[]>();
|
|
24
|
-
jest.mock('gh-cookie', () => ({
|
|
25
|
-
getCookieContent: (...args: unknown[]): Promise<unknown> =>
|
|
26
|
-
mockGetCookieContent(...args),
|
|
27
|
-
}));
|
|
28
22
|
describe('BaseGitHubRepository', () => {
|
|
29
23
|
const jsonFilePath = './tmp/github.com.cookies.json';
|
|
30
24
|
const localStorageRepository = new LocalStorageRepository();
|
|
@@ -133,7 +127,6 @@ describe('BaseGitHubRepository', () => {
|
|
|
133
127
|
'dummy-password',
|
|
134
128
|
'dummy-authenticator-key',
|
|
135
129
|
);
|
|
136
|
-
this.cookieRefreshRetryDelayMs = 0;
|
|
137
130
|
}
|
|
138
131
|
}
|
|
139
132
|
|
|
@@ -153,8 +146,6 @@ describe('BaseGitHubRepository', () => {
|
|
|
153
146
|
beforeEach(() => {
|
|
154
147
|
mockKyGet.mockReset().mockReturnValue({ text: mockKyGetText });
|
|
155
148
|
mockKyGetText.mockReset();
|
|
156
|
-
mockGetCookieContent.mockReset();
|
|
157
|
-
mockGetCookieContent.mockResolvedValue(validCookieJson);
|
|
158
149
|
fs.writeFileSync(refreshCookieJsonFilePath, validCookieJson);
|
|
159
150
|
});
|
|
160
151
|
|
|
@@ -182,10 +173,7 @@ describe('BaseGitHubRepository', () => {
|
|
|
182
173
|
it('should fail when HTML contains username in content but not in user-login meta tag (not logged in)', async () => {
|
|
183
174
|
const repository = new RefreshTestRepository();
|
|
184
175
|
const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body><h1>${ghUserName}</h1><p>Public profile</p></body></html>`;
|
|
185
|
-
mockKyGetText
|
|
186
|
-
.mockResolvedValueOnce(notLoggedInHtml)
|
|
187
|
-
.mockResolvedValueOnce(notLoggedInHtml)
|
|
188
|
-
.mockResolvedValueOnce(notLoggedInHtml);
|
|
176
|
+
mockKyGetText.mockResolvedValueOnce(notLoggedInHtml);
|
|
189
177
|
|
|
190
178
|
await expect(repository.refreshCookie()).rejects.toThrow(
|
|
191
179
|
'Failed to refresh cookie',
|
|
@@ -210,47 +198,14 @@ describe('BaseGitHubRepository', () => {
|
|
|
210
198
|
);
|
|
211
199
|
});
|
|
212
200
|
|
|
213
|
-
it('should throw when
|
|
201
|
+
it('should throw when the authentication check fails', async () => {
|
|
214
202
|
const repository = new RefreshTestRepository();
|
|
215
203
|
const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body></body></html>`;
|
|
216
|
-
mockKyGetText
|
|
217
|
-
.mockResolvedValueOnce(notLoggedInHtml)
|
|
218
|
-
.mockResolvedValueOnce(notLoggedInHtml)
|
|
219
|
-
.mockResolvedValueOnce(notLoggedInHtml);
|
|
204
|
+
mockKyGetText.mockResolvedValueOnce(notLoggedInHtml);
|
|
220
205
|
|
|
221
206
|
await expect(repository.refreshCookie()).rejects.toThrow(
|
|
222
207
|
'Failed to refresh cookie',
|
|
223
208
|
);
|
|
224
209
|
});
|
|
225
|
-
|
|
226
|
-
it('should reset cookie cache before regenerating so new cookie is used', async () => {
|
|
227
|
-
const repository = new RefreshTestRepository();
|
|
228
|
-
mockKyGetText
|
|
229
|
-
.mockResolvedValueOnce(
|
|
230
|
-
`<html><head><meta name="user-login" content=""></head><body></body></html>`,
|
|
231
|
-
)
|
|
232
|
-
.mockResolvedValueOnce(
|
|
233
|
-
`<html><head><meta name="user-login" content="${ghUserName}"></head><body></body></html>`,
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
await expect(repository.refreshCookie()).resolves.toBeUndefined();
|
|
237
|
-
expect(mockKyGet).toHaveBeenCalledTimes(2);
|
|
238
|
-
expect(mockGetCookieContent).toHaveBeenCalledTimes(1);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('should succeed on third attempt after two failed cookie refresh attempts', async () => {
|
|
242
|
-
const repository = new RefreshTestRepository();
|
|
243
|
-
const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body></body></html>`;
|
|
244
|
-
mockKyGetText
|
|
245
|
-
.mockResolvedValueOnce(notLoggedInHtml)
|
|
246
|
-
.mockResolvedValueOnce(notLoggedInHtml)
|
|
247
|
-
.mockResolvedValueOnce(
|
|
248
|
-
`<html><head><meta name="user-login" content="${ghUserName}"></head><body></body></html>`,
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
await expect(repository.refreshCookie()).resolves.toBeUndefined();
|
|
252
|
-
expect(mockKyGet).toHaveBeenCalledTimes(3);
|
|
253
|
-
expect(mockGetCookieContent).toHaveBeenCalledTimes(2);
|
|
254
|
-
});
|
|
255
210
|
});
|
|
256
211
|
});
|