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.
Files changed (99) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +70 -25
  3. package/bin/adapter/entry-points/cli/index.js +2 -104
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +0 -15
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +28 -62
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/situationFileWriter.js +98 -0
  10. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -0
  11. package/bin/adapter/repositories/GraphqlProjectRepository.js +37 -0
  12. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  13. package/bin/domain/entities/WorkflowStatus.js +36 -0
  14. package/bin/domain/entities/WorkflowStatus.js.map +1 -0
  15. package/bin/domain/usecases/AnalyzeStoriesUseCase.js +2 -1
  16. package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
  17. package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js +4 -3
  18. package/bin/domain/usecases/ChangeStatusByStoryColorUseCase.js.map +1 -1
  19. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +2 -1
  20. package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js.map +1 -1
  21. package/bin/domain/usecases/CreateEstimationIssueUseCase.js +2 -1
  22. package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
  23. package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
  24. package/bin/domain/usecases/HandleScheduledEventUseCase.js +9 -17
  25. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  26. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +13 -15
  27. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  28. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +4 -5
  29. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  30. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +47 -0
  31. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -0
  32. package/bin/domain/usecases/StartPreparationUseCase.js +7 -8
  33. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  34. package/bin/domain/usecases/UpdateIssueStatusByLabelUseCase.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/adapter/entry-points/cli/index.test.ts +8 -258
  37. package/src/adapter/entry-points/cli/index.ts +6 -106
  38. package/src/adapter/entry-points/cli/projectConfig.ts +0 -33
  39. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +24 -58
  40. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +36 -41
  41. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +417 -0
  42. package/src/adapter/entry-points/handlers/situationFileWriter.ts +168 -0
  43. package/src/adapter/repositories/GraphqlProjectRepository.ts +55 -1
  44. package/src/domain/entities/WorkflowStatus.ts +41 -0
  45. package/src/domain/usecases/AnalyzeStoriesUseCase.ts +2 -2
  46. package/src/domain/usecases/ChangeStatusByStoryColorUseCase.test.ts +5 -10
  47. package/src/domain/usecases/ChangeStatusByStoryColorUseCase.ts +4 -4
  48. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +0 -11
  49. package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +2 -2
  50. package/src/domain/usecases/CreateEstimationIssueUseCase.ts +2 -2
  51. package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +0 -4
  52. package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +0 -1
  53. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -41
  54. package/src/domain/usecases/HandleScheduledEventUseCase.ts +9 -27
  55. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +0 -202
  56. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +18 -31
  57. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +13 -101
  58. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +10 -10
  59. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +187 -0
  60. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +69 -0
  61. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -151
  62. package/src/domain/usecases/StartPreparationUseCase.ts +11 -20
  63. package/src/domain/usecases/UpdateIssueStatusByLabelUseCase.test.ts +2 -47
  64. package/src/domain/usecases/UpdateIssueStatusByLabelUseCase.ts +1 -5
  65. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +6 -1
  66. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  67. package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -3
  68. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  69. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  70. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +30 -0
  71. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -0
  72. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +4 -1
  73. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  74. package/types/domain/entities/WorkflowStatus.d.ts +13 -0
  75. package/types/domain/entities/WorkflowStatus.d.ts.map +1 -0
  76. package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts +0 -1
  77. package/types/domain/usecases/AnalyzeStoriesUseCase.d.ts.map +1 -1
  78. package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts +0 -1
  79. package/types/domain/usecases/ChangeStatusByStoryColorUseCase.d.ts.map +1 -1
  80. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +0 -1
  81. package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
  82. package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts +0 -1
  83. package/types/domain/usecases/CreateEstimationIssueUseCase.d.ts.map +1 -1
  84. package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts +0 -1
  85. package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
  86. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -8
  87. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  88. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -4
  89. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  90. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -3
  91. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  92. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts +10 -0
  93. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -0
  94. package/types/domain/usecases/StartPreparationUseCase.d.ts +1 -3
  95. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  96. package/types/domain/usecases/UpdateIssueStatusByLabelUseCase.d.ts +0 -1
  97. package/types/domain/usecases/UpdateIssueStatusByLabelUseCase.d.ts.map +1 -1
  98. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +3 -1
  99. 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
- 'getProject' | 'findProjectIdByUrl' | 'getByUrl' | 'updateStoryList'
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
+ ];