github-issue-tower-defence-management 1.46.0 → 1.48.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 +24 -0
- package/README.md +70 -25
- package/bin/adapter/entry-points/cli/index.js +2 -104
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +0 -15
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +28 -62
- 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/GraphqlProjectRepository.js +37 -0
- package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
- package/bin/domain/entities/WorkflowStatus.js +36 -0
- package/bin/domain/entities/WorkflowStatus.js.map +1 -0
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js +2 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
- package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js +4 -3
- package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js.map +1 -1
- package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +2 -1
- package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js +2 -1
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +9 -17
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +13 -15
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +4 -5
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +47 -0
- package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -0
- package/bin/domain/usecases/StartPreparationUseCase.js +7 -8
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/UpdateIssueStatusByLabelUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +8 -258
- package/src/adapter/entry-points/cli/index.ts +6 -106
- package/src/adapter/entry-points/cli/projectConfig.ts +0 -33
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +24 -58
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +36 -41
- 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/GraphqlProjectRepository.ts +55 -1
- package/src/domain/entities/WorkflowStatus.ts +41 -0
- package/src/domain/usecases/AnalyzeStoriesUseCase.ts +2 -2
- package/src/domain/usecases/ChangeStatusByStoryColorUseCase.test.ts +5 -10
- package/src/domain/usecases/ChangeStatusByStoryColorUseCase.ts +4 -4
- package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +0 -11
- package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +2 -2
- package/src/domain/usecases/CreateEstimationIssueUseCase.ts +2 -2
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +0 -4
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +0 -1
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -41
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +9 -27
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +0 -202
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +18 -31
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +13 -101
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +10 -10
- package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +187 -0
- package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +69 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -151
- package/src/domain/usecases/StartPreparationUseCase.ts +11 -20
- package/src/domain/usecases/UpdateIssueStatusByLabelUseCase.test.ts +2 -47
- package/src/domain/usecases/UpdateIssueStatusByLabelUseCase.ts +1 -5
- package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +6 -1
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -3
- package/types/adapter/entry-points/cli/projectConfig.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/GraphqlProjectRepository.d.ts +4 -1
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
- package/types/domain/entities/WorkflowStatus.d.ts +13 -0
- package/types/domain/entities/WorkflowStatus.d.ts.map +1 -0
- package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts +0 -1
- package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts.map +1 -1
- package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts +0 -1
- package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts.map +1 -1
- package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +0 -1
- package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts +0 -1
- package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts +0 -1
- package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -8
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -4
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -3
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts +10 -0
- package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -0
- package/types/domain/usecases/StartPreparationUseCase.d.ts +1 -3
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/UpdateIssueStatusByLabelUseCase.d.ts +0 -1
- package/types/domain/usecases/UpdateIssueStatusByLabelUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +3 -1
- package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -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
|
+
};
|
|
@@ -9,7 +9,11 @@ export class GraphqlProjectRepository
|
|
|
9
9
|
implements
|
|
10
10
|
Pick<
|
|
11
11
|
ProjectRepository,
|
|
12
|
-
|
|
12
|
+
| 'getProject'
|
|
13
|
+
| 'findProjectIdByUrl'
|
|
14
|
+
| 'getByUrl'
|
|
15
|
+
| 'updateStoryList'
|
|
16
|
+
| 'updateStatusList'
|
|
13
17
|
>
|
|
14
18
|
{
|
|
15
19
|
extractProjectFromUrl = (
|
|
@@ -362,4 +366,54 @@ export class GraphqlProjectRepository
|
|
|
362
366
|
}>();
|
|
363
367
|
return response.data.updateProjectV2Field.projectV2Field.options;
|
|
364
368
|
};
|
|
369
|
+
updateStatusList = async (
|
|
370
|
+
project: Project,
|
|
371
|
+
newStatusList: (Omit<FieldOption, 'id'> & {
|
|
372
|
+
id: FieldOption['id'] | null;
|
|
373
|
+
})[],
|
|
374
|
+
): Promise<FieldOption[]> => {
|
|
375
|
+
const mutation = `mutation UpdateStatusOptions($fieldId: ID!, $options: [ProjectV2SingleSelectFieldOptionInput!]!) {
|
|
376
|
+
updateProjectV2Field(input: {
|
|
377
|
+
fieldId: $fieldId
|
|
378
|
+
singleSelectOptions: $options
|
|
379
|
+
}) {
|
|
380
|
+
projectV2Field {
|
|
381
|
+
... on ProjectV2SingleSelectField {
|
|
382
|
+
options {
|
|
383
|
+
id
|
|
384
|
+
name
|
|
385
|
+
color
|
|
386
|
+
description
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}`;
|
|
392
|
+
const variables = {
|
|
393
|
+
fieldId: project.status.fieldId,
|
|
394
|
+
options: newStatusList.map(({ id, name, color, description }) => ({
|
|
395
|
+
...(id !== null ? { id } : {}),
|
|
396
|
+
name,
|
|
397
|
+
color,
|
|
398
|
+
description,
|
|
399
|
+
})),
|
|
400
|
+
};
|
|
401
|
+
const response = await ky
|
|
402
|
+
.post('https://api.github.com/graphql', {
|
|
403
|
+
json: { query: mutation, variables },
|
|
404
|
+
headers: {
|
|
405
|
+
Authorization: `Bearer ${this.ghToken}`,
|
|
406
|
+
},
|
|
407
|
+
})
|
|
408
|
+
.json<{
|
|
409
|
+
data: {
|
|
410
|
+
updateProjectV2Field: {
|
|
411
|
+
projectV2Field: {
|
|
412
|
+
options: FieldOption[];
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
}>();
|
|
417
|
+
return response.data.updateProjectV2Field.projectV2Field.options;
|
|
418
|
+
};
|
|
365
419
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { FieldOption } from './Project';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_STATUS_NAME = 'Unread';
|
|
4
|
+
export const AWAITING_WORKSPACE_STATUS_NAME = 'Awaiting Workspace';
|
|
5
|
+
export const PREPARATION_STATUS_NAME = 'Preparation';
|
|
6
|
+
export const AWAITING_QUALITY_CHECK_STATUS_NAME = 'Awaiting Quality Check';
|
|
7
|
+
export const DISABLED_STATUS_NAME = 'Disabled';
|
|
8
|
+
|
|
9
|
+
export type WorkflowStatusDefinition = {
|
|
10
|
+
name: string;
|
|
11
|
+
color: FieldOption['color'];
|
|
12
|
+
description: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const REQUIRED_WORKFLOW_STATUSES: WorkflowStatusDefinition[] = [
|
|
16
|
+
{
|
|
17
|
+
name: DEFAULT_STATUS_NAME,
|
|
18
|
+
color: 'GRAY',
|
|
19
|
+
description: 'Default fallback status for issues before triage',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: AWAITING_WORKSPACE_STATUS_NAME,
|
|
23
|
+
color: 'YELLOW',
|
|
24
|
+
description: 'Issue is ready and waiting for an agent workspace',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: PREPARATION_STATUS_NAME,
|
|
28
|
+
color: 'ORANGE',
|
|
29
|
+
description: 'Agent is preparing the issue',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: AWAITING_QUALITY_CHECK_STATUS_NAME,
|
|
33
|
+
color: 'BLUE',
|
|
34
|
+
description: 'Awaiting human quality check',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: DISABLED_STATUS_NAME,
|
|
38
|
+
color: 'GRAY',
|
|
39
|
+
description: 'Disabled and excluded from the active workflow',
|
|
40
|
+
},
|
|
41
|
+
];
|