github-issue-tower-defence-management 1.60.2 → 1.63.1

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 (117) hide show
  1. package/.github/workflows/publish.yml +13 -0
  2. package/.github/workflows/test.yml +0 -4
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +53 -10
  5. package/bin/adapter/entry-points/cli/index.js +11 -11
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +3 -22
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -22
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js +56 -0
  12. package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/situationFileWriter.js +5 -0
  14. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
  15. package/bin/adapter/proxy/TokenListLoader.js +21 -6
  16. package/bin/adapter/proxy/TokenListLoader.js.map +1 -1
  17. package/bin/adapter/proxy/proxyEntry.js +1 -0
  18. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  19. package/bin/adapter/repositories/BaseGitHubRepository.js +1 -113
  20. package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
  21. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +5 -3
  22. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  23. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +8 -7
  24. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  25. package/bin/domain/usecases/HandleScheduledEventUseCase.js +14 -3
  26. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  27. package/bin/domain/usecases/IssueRejectionEvaluator.js +8 -1
  28. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  29. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +5 -1
  30. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  31. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
  32. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  33. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js +32 -1
  34. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js.map +1 -1
  35. package/bin/domain/usecases/StartPreparationUseCase.js +91 -12
  36. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  37. package/package.json +1 -4
  38. package/src/adapter/entry-points/cli/index.test.ts +16 -16
  39. package/src/adapter/entry-points/cli/index.ts +8 -11
  40. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +2 -55
  41. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +1 -11
  42. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -56
  43. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -11
  44. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.test.ts +177 -0
  45. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.ts +20 -0
  46. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +36 -0
  47. package/src/adapter/entry-points/handlers/situationFileWriter.ts +8 -0
  48. package/src/adapter/proxy/TokenListLoader.test.ts +50 -1
  49. package/src/adapter/proxy/TokenListLoader.ts +25 -5
  50. package/src/adapter/proxy/proxyEntry.test.ts +270 -1
  51. package/src/adapter/proxy/proxyEntry.ts +2 -1
  52. package/src/adapter/repositories/BaseGitHubRepository.test.ts +1 -186
  53. package/src/adapter/repositories/BaseGitHubRepository.ts +1 -139
  54. package/src/adapter/repositories/GraphqlProjectRepository.errorHandling.test.ts +0 -1
  55. package/src/adapter/repositories/GraphqlProjectRepository.fetchProjectId.test.ts +4 -1
  56. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +60 -19
  57. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -4
  58. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +23 -13
  59. package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +0 -1
  60. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +0 -8
  61. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +0 -1
  62. package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
  63. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
  64. package/src/domain/usecases/HandleScheduledEventUseCase.ts +20 -5
  65. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +153 -0
  66. package/src/domain/usecases/IssueRejectionEvaluator.ts +8 -0
  67. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +175 -31
  68. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +7 -1
  69. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +32 -0
  70. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +39 -5
  71. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -1
  72. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.test.ts +139 -20
  73. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.ts +62 -2
  74. package/src/domain/usecases/StartPreparationUseCase.test.ts +404 -21
  75. package/src/domain/usecases/StartPreparationUseCase.ts +152 -16
  76. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +16 -0
  77. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  78. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  79. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  80. package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts +3 -0
  81. package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts.map +1 -0
  82. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +1 -0
  83. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
  84. package/types/adapter/proxy/TokenListLoader.d.ts +5 -0
  85. package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -1
  86. package/types/adapter/proxy/proxyEntry.d.ts +2 -1
  87. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  88. package/types/adapter/repositories/BaseGitHubRepository.d.ts +1 -23
  89. package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
  90. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  91. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +14 -5
  92. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  93. package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
  94. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  95. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -2
  96. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  97. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +1 -1
  98. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  99. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  100. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts +5 -2
  101. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts.map +1 -1
  102. package/types/domain/usecases/StartPreparationUseCase.d.ts +15 -1
  103. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  104. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +14 -0
  105. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  106. package/bin/adapter/repositories/issue/CheerioIssueRepository.js +0 -136
  107. package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +0 -1
  108. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js +0 -1606
  109. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js.map +0 -1
  110. package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +0 -6552
  111. package/src/adapter/repositories/issue/CheerioIssueRepository.ts +0 -142
  112. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +0 -118
  113. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.ts +0 -584
  114. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +0 -40
  115. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +0 -1
  116. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts +0 -220
  117. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts.map +0 -1
@@ -93,43 +93,17 @@ describe('GetStoryObjectMapUseCaseHandler', () => {
93
93
  );
94
94
  });
95
95
 
96
- it('should pass bot credentials to repository constructors when provided', async () => {
97
- const configWithCredentials = {
98
- ...validConfig,
99
- credentials: {
100
- bot: {
101
- github: {
102
- token: 'test-token',
103
- name: 'bot-user',
104
- password: 'bot-pass',
105
- authenticatorKey: 'bot-auth-key',
106
- },
107
- },
108
- },
109
- };
110
- jest
111
- .mocked(fs.readFileSync)
112
- .mockReturnValue(YAML.stringify(configWithCredentials));
113
-
96
+ it('should pass bot token to repository constructors', async () => {
114
97
  const handler = new GetStoryObjectMapUseCaseHandler();
115
98
  await handler.handle('config.yml', false);
116
99
 
117
- const expectedCookiePath = `./tmp/cache/${validConfig.projectName}/github.com.cookies.json`;
118
-
119
100
  for (const MockedClass of [
120
101
  MockedGraphqlProjectRepository,
121
102
  MockedApiV3IssueRepository,
122
103
  MockedRestIssueRepository,
123
104
  MockedGraphqlProjectItemRepository,
124
105
  ]) {
125
- expect(MockedClass).toHaveBeenCalledWith(
126
- expect.anything(),
127
- expectedCookiePath,
128
- 'test-token',
129
- 'bot-user',
130
- 'bot-pass',
131
- 'bot-auth-key',
132
- );
106
+ expect(MockedClass).toHaveBeenCalledWith(expect.anything(), 'test-token');
133
107
  }
134
108
 
135
109
  expect(MockedApiV3CheerioRestIssueRepository).toHaveBeenCalledWith(
@@ -138,34 +112,7 @@ describe('GetStoryObjectMapUseCaseHandler', () => {
138
112
  expect.anything(),
139
113
  expect.anything(),
140
114
  expect.anything(),
141
- expectedCookiePath,
142
115
  'test-token',
143
- 'bot-user',
144
- 'bot-pass',
145
- 'bot-auth-key',
146
116
  );
147
117
  });
148
-
149
- it('should pass undefined credentials when not provided in config', async () => {
150
- const handler = new GetStoryObjectMapUseCaseHandler();
151
- await handler.handle('config.yml', false);
152
-
153
- const expectedCookiePath = `./tmp/cache/${validConfig.projectName}/github.com.cookies.json`;
154
-
155
- for (const MockedClass of [
156
- MockedGraphqlProjectRepository,
157
- MockedApiV3IssueRepository,
158
- MockedRestIssueRepository,
159
- MockedGraphqlProjectItemRepository,
160
- ]) {
161
- expect(MockedClass).toHaveBeenCalledWith(
162
- expect.anything(),
163
- expectedCookiePath,
164
- 'test-token',
165
- undefined,
166
- undefined,
167
- undefined,
168
- );
169
- }
170
- });
171
118
  });
@@ -33,9 +33,6 @@ export class GetStoryObjectMapUseCaseHandler {
33
33
  bot: {
34
34
  github: {
35
35
  token: string;
36
- name?: string;
37
- password?: string;
38
- authenticatorKey?: string;
39
36
  };
40
37
  };
41
38
  };
@@ -54,14 +51,7 @@ export class GetStoryObjectMapUseCaseHandler {
54
51
  );
55
52
  const githubRepositoryParams: ConstructorParameters<
56
53
  typeof BaseGitHubRepository
57
- > = [
58
- localStorageRepository,
59
- `${cachePath}/github.com.cookies.json`,
60
- input.credentials.bot.github.token,
61
- input.credentials.bot.github.name,
62
- input.credentials.bot.github.password,
63
- input.credentials.bot.github.authenticatorKey,
64
- ];
54
+ > = [localStorageRepository, input.credentials.bot.github.token];
65
55
  const projectRepository = {
66
56
  ...new GraphqlProjectRepository(...githubRepositoryParams),
67
57
  };
@@ -23,6 +23,7 @@ const mockRun = jest.fn().mockImplementation((...args: Parameters<RunFn>) => {
23
23
  issues: [],
24
24
  cacheUsed: false,
25
25
  targetDateTimes: [],
26
+ rotationOrder: null,
26
27
  });
27
28
  });
28
29
 
@@ -114,6 +115,9 @@ jest.mock('../../repositories/GitHubIssueCommentRepository', () => ({
114
115
  jest.mock('./situationFileWriter', () => ({
115
116
  writeSituationFile: jest.fn().mockResolvedValue(undefined),
116
117
  }));
118
+ jest.mock('./rotationOrderFileWriter', () => ({
119
+ writeRotationOrderFile: jest.fn(),
120
+ }));
117
121
 
118
122
  import { HandleScheduledEventUseCaseHandler } from './HandleScheduledEventUseCaseHandler';
119
123
  import { writeSituationFile } from './situationFileWriter';
@@ -185,44 +189,17 @@ describe('HandleScheduledEventUseCaseHandler', () => {
185
189
  mockFetchReturningReadme(null);
186
190
  });
187
191
 
188
- it('should pass bot credentials to repository constructors when provided', async () => {
189
- const configWithCredentials = {
190
- ...validConfig,
191
- credentials: {
192
- ...validConfig.credentials,
193
- bot: {
194
- github: {
195
- token: 'test-token',
196
- name: 'bot-user',
197
- password: 'bot-pass',
198
- authenticatorKey: 'bot-auth-key',
199
- },
200
- },
201
- },
202
- };
203
- jest
204
- .mocked(fs.readFileSync)
205
- .mockReturnValue(YAML.stringify(configWithCredentials));
206
-
192
+ it('should pass bot token to repository constructors', async () => {
207
193
  const handler = new HandleScheduledEventUseCaseHandler();
208
194
  await handler.handle('config.yml', false);
209
195
 
210
- const expectedCookiePath = `./tmp/cache/${validConfig.projectName}/github.com.cookies.json`;
211
-
212
196
  for (const MockedClass of [
213
197
  MockedGraphqlProjectRepository,
214
198
  MockedApiV3IssueRepository,
215
199
  MockedRestIssueRepository,
216
200
  MockedGraphqlProjectItemRepository,
217
201
  ]) {
218
- expect(MockedClass).toHaveBeenCalledWith(
219
- expect.anything(),
220
- expectedCookiePath,
221
- 'test-token',
222
- 'bot-user',
223
- 'bot-pass',
224
- 'bot-auth-key',
225
- );
202
+ expect(MockedClass).toHaveBeenCalledWith(expect.anything(), 'test-token');
226
203
  }
227
204
 
228
205
  expect(MockedApiV3CheerioRestIssueRepository).toHaveBeenCalledWith(
@@ -231,37 +208,10 @@ describe('HandleScheduledEventUseCaseHandler', () => {
231
208
  expect.anything(),
232
209
  expect.anything(),
233
210
  expect.anything(),
234
- expectedCookiePath,
235
211
  'test-token',
236
- 'bot-user',
237
- 'bot-pass',
238
- 'bot-auth-key',
239
212
  );
240
213
  });
241
214
 
242
- it('should pass undefined credentials when not provided in config', async () => {
243
- const handler = new HandleScheduledEventUseCaseHandler();
244
- await handler.handle('config.yml', false);
245
-
246
- const expectedCookiePath = `./tmp/cache/${validConfig.projectName}/github.com.cookies.json`;
247
-
248
- for (const MockedClass of [
249
- MockedGraphqlProjectRepository,
250
- MockedApiV3IssueRepository,
251
- MockedRestIssueRepository,
252
- MockedGraphqlProjectItemRepository,
253
- ]) {
254
- expect(MockedClass).toHaveBeenCalledWith(
255
- expect.anything(),
256
- expectedCookiePath,
257
- 'test-token',
258
- undefined,
259
- undefined,
260
- undefined,
261
- );
262
- }
263
- });
264
-
265
215
  it('should write situation file after successful run with resolved config values', async () => {
266
216
  const configWithPreparation = {
267
217
  ...validConfig,
@@ -2,6 +2,7 @@ import YAML from 'yaml';
2
2
  import TYPIA from 'typia';
3
3
  import fs from 'fs';
4
4
  import { writeSituationFile } from './situationFileWriter';
5
+ import { writeRotationOrderFile } from './rotationOrderFileWriter';
5
6
  import {
6
7
  fetchProjectReadme,
7
8
  parseProjectReadmeConfig,
@@ -44,6 +45,7 @@ import { SetupTowerDefenceProjectUseCase } from '../../../domain/usecases/SetupT
44
45
  import {
45
46
  AWAITING_QUALITY_CHECK_STATUS_NAME,
46
47
  AWAITING_WORKSPACE_STATUS_NAME,
48
+ FAILED_PREPARATION_STATUS_NAME,
47
49
  PREPARATION_STATUS_NAME,
48
50
  } from '../../../domain/entities/WorkflowStatus';
49
51
 
@@ -76,9 +78,6 @@ export class HandleScheduledEventUseCaseHandler {
76
78
  bot: {
77
79
  github: {
78
80
  token: string;
79
- name?: string;
80
- password?: string;
81
- authenticatorKey?: string;
82
81
  };
83
82
  };
84
83
  };
@@ -151,14 +150,7 @@ export class HandleScheduledEventUseCaseHandler {
151
150
  );
152
151
  const githubRepositoryParams: ConstructorParameters<
153
152
  typeof BaseGitHubRepository
154
- > = [
155
- localStorageRepository,
156
- `${cachePath}/github.com.cookies.json`,
157
- input.credentials.bot.github.token,
158
- input.credentials.bot.github.name,
159
- input.credentials.bot.github.password,
160
- input.credentials.bot.github.authenticatorKey,
161
- ];
153
+ > = [localStorageRepository, input.credentials.bot.github.token];
162
154
  const projectRepository = new GraphqlProjectRepository(
163
155
  ...githubRepositoryParams,
164
156
  );
@@ -281,6 +273,9 @@ export class HandleScheduledEventUseCaseHandler {
281
273
 
282
274
  const result = await handleScheduledEventUseCase.run(mergedInput);
283
275
  if (result) {
276
+ if (result.rotationOrder !== null) {
277
+ writeRotationOrderFile(result.rotationOrder);
278
+ }
284
279
  await writeSituationFile({
285
280
  cachePath,
286
281
  projectId: result.project.id,
@@ -289,6 +284,7 @@ export class HandleScheduledEventUseCaseHandler {
289
284
  awaitingQualityCheckStatus: AWAITING_QUALITY_CHECK_STATUS_NAME,
290
285
  preparationStatus: PREPARATION_STATUS_NAME,
291
286
  awaitingWorkspaceStatus: AWAITING_WORKSPACE_STATUS_NAME,
287
+ failedPreparationStatus: FAILED_PREPARATION_STATUS_NAME,
292
288
  },
293
289
  config: {
294
290
  maximumPreparingIssuesCount:
@@ -0,0 +1,177 @@
1
+ import fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { writeRotationOrderFile } from './rotationOrderFileWriter';
5
+ import type { RotationOrderEntry } from '../../../domain/usecases/StartPreparationUseCase';
6
+
7
+ jest.mock('fs');
8
+
9
+ const TOKEN_A = 'sk-ant-secret-token-a-value';
10
+ const TOKEN_B = 'sk-ant-secret-token-b-value';
11
+
12
+ describe('writeRotationOrderFile', () => {
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ jest.mocked(fs.mkdirSync).mockReturnValue(undefined);
16
+ jest.mocked(fs.writeFileSync).mockReturnValue(undefined);
17
+ jest.mocked(fs.renameSync).mockReturnValue(undefined);
18
+ });
19
+
20
+ it('writes rotation order entries sorted selected-first to the stable path under XDG_CACHE_HOME', () => {
21
+ const originalXdg = process.env.XDG_CACHE_HOME;
22
+ process.env.XDG_CACHE_HOME = '/custom/cache';
23
+
24
+ const entries: RotationOrderEntry[] = [
25
+ {
26
+ name: 'personal-1',
27
+ fiveHourUtilization: 0.2,
28
+ blocked: false,
29
+ rejected: false,
30
+ thresholdExcluded: false,
31
+ },
32
+ ];
33
+
34
+ writeRotationOrderFile(entries);
35
+
36
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
37
+ '/custom/cache/tdpm/rotation-order.json.tmp',
38
+ expect.any(String),
39
+ );
40
+ expect(jest.mocked(fs.renameSync)).toHaveBeenCalledWith(
41
+ '/custom/cache/tdpm/rotation-order.json.tmp',
42
+ '/custom/cache/tdpm/rotation-order.json',
43
+ );
44
+
45
+ process.env.XDG_CACHE_HOME = originalXdg;
46
+ });
47
+
48
+ it('falls back to ~/.cache/tdpm/rotation-order.json when XDG_CACHE_HOME is unset', () => {
49
+ const originalXdg = process.env.XDG_CACHE_HOME;
50
+ delete process.env.XDG_CACHE_HOME;
51
+
52
+ const home = os.homedir();
53
+ const expectedPath = path.join(
54
+ home,
55
+ '.cache',
56
+ 'tdpm',
57
+ 'rotation-order.json',
58
+ );
59
+
60
+ writeRotationOrderFile([]);
61
+
62
+ expect(jest.mocked(fs.renameSync)).toHaveBeenCalledWith(
63
+ `${expectedPath}.tmp`,
64
+ expectedPath,
65
+ );
66
+
67
+ process.env.XDG_CACHE_HOME = originalXdg;
68
+ });
69
+
70
+ it('includes name, fiveHourUtilization, blocked, rejected, and thresholdExcluded in output', () => {
71
+ process.env.XDG_CACHE_HOME = '/cache';
72
+
73
+ const entries: RotationOrderEntry[] = [
74
+ {
75
+ name: 'personal-1',
76
+ fiveHourUtilization: 0.3,
77
+ blocked: false,
78
+ rejected: false,
79
+ thresholdExcluded: false,
80
+ },
81
+ {
82
+ name: 'personal-2',
83
+ fiveHourUtilization: 0.95,
84
+ blocked: false,
85
+ rejected: false,
86
+ thresholdExcluded: true,
87
+ },
88
+ ];
89
+
90
+ writeRotationOrderFile(entries);
91
+
92
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
93
+ expect.any(String),
94
+ expect.stringContaining('"name":"personal-1"'),
95
+ );
96
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
97
+ expect.any(String),
98
+ expect.stringContaining('"name":"personal-2"'),
99
+ );
100
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
101
+ expect.any(String),
102
+ expect.stringContaining('"fiveHourUtilization":0.3'),
103
+ );
104
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
105
+ expect.any(String),
106
+ expect.stringContaining('"thresholdExcluded":true'),
107
+ );
108
+
109
+ delete process.env.XDG_CACHE_HOME;
110
+ });
111
+
112
+ it('does not write raw token values to the output file', () => {
113
+ process.env.XDG_CACHE_HOME = '/cache';
114
+
115
+ const entries: RotationOrderEntry[] = [
116
+ {
117
+ name: 'personal-1',
118
+ fiveHourUtilization: 0.1,
119
+ blocked: false,
120
+ rejected: false,
121
+ thresholdExcluded: false,
122
+ },
123
+ ];
124
+
125
+ writeRotationOrderFile(entries);
126
+
127
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
128
+ expect.any(String),
129
+ expect.not.stringContaining(TOKEN_A),
130
+ );
131
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
132
+ expect.any(String),
133
+ expect.not.stringContaining(TOKEN_B),
134
+ );
135
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
136
+ expect.any(String),
137
+ expect.not.stringContaining('sk-ant-'),
138
+ );
139
+
140
+ delete process.env.XDG_CACHE_HOME;
141
+ });
142
+
143
+ it('writes atomically: mkdirSync before writeFileSync before renameSync', () => {
144
+ process.env.XDG_CACHE_HOME = '/cache';
145
+
146
+ const callOrder: string[] = [];
147
+ jest.mocked(fs.mkdirSync).mockImplementation((): undefined => {
148
+ callOrder.push('mkdir');
149
+ return undefined;
150
+ });
151
+ jest.mocked(fs.writeFileSync).mockImplementation((): void => {
152
+ callOrder.push('write');
153
+ });
154
+ jest.mocked(fs.renameSync).mockImplementation((): void => {
155
+ callOrder.push('rename');
156
+ });
157
+
158
+ writeRotationOrderFile([]);
159
+
160
+ expect(callOrder).toEqual(['mkdir', 'write', 'rename']);
161
+
162
+ delete process.env.XDG_CACHE_HOME;
163
+ });
164
+
165
+ it('writes an empty array when no rotation entries are provided', () => {
166
+ process.env.XDG_CACHE_HOME = '/cache';
167
+
168
+ writeRotationOrderFile([]);
169
+
170
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
171
+ expect.any(String),
172
+ '[]',
173
+ );
174
+
175
+ delete process.env.XDG_CACHE_HOME;
176
+ });
177
+ });
@@ -0,0 +1,20 @@
1
+ import fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import type { RotationOrderEntry } from '../../../domain/usecases/StartPreparationUseCase';
5
+
6
+ const rotationOrderFilePath = (): string => {
7
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
8
+ return path.join(base, 'tdpm', 'rotation-order.json');
9
+ };
10
+
11
+ export const writeRotationOrderFile = (
12
+ rotationOrder: RotationOrderEntry[],
13
+ ): void => {
14
+ const filePath = rotationOrderFilePath();
15
+ const dir = path.dirname(filePath);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ const tmpPath = `${filePath}.tmp`;
18
+ fs.writeFileSync(tmpPath, JSON.stringify(rotationOrder));
19
+ fs.renameSync(tmpPath, filePath);
20
+ };
@@ -50,6 +50,7 @@ const baseParams = {
50
50
  awaitingQualityCheckStatus: 'Awaiting quality check',
51
51
  preparationStatus: 'Preparation',
52
52
  awaitingWorkspaceStatus: 'Awaiting workspace',
53
+ failedPreparationStatus: 'Failed Preparation',
53
54
  },
54
55
  config: {
55
56
  maximumPreparingIssuesCount: 6,
@@ -183,6 +184,7 @@ describe('writeSituationFile', () => {
183
184
  awaitingQualityCheckStatus: null,
184
185
  preparationStatus: null,
185
186
  awaitingWorkspaceStatus: null,
187
+ failedPreparationStatus: null,
186
188
  },
187
189
  issues,
188
190
  };
@@ -207,6 +209,40 @@ describe('writeSituationFile', () => {
207
209
  expect.any(String),
208
210
  expect.stringContaining('"awaitingWorkspaceBlockedByDependency":0'),
209
211
  );
212
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
213
+ expect.any(String),
214
+ expect.stringContaining('"failedPreparation":0'),
215
+ );
216
+ });
217
+
218
+ it('counts failedPreparation correctly from fixture issues', async () => {
219
+ const issues = [
220
+ createIssue({ status: 'Failed Preparation' }),
221
+ createIssue({ status: 'Failed Preparation' }),
222
+ createIssue({ status: 'Preparation' }),
223
+ createIssue({ status: 'Awaiting workspace' }),
224
+ ];
225
+
226
+ await writeSituationFile({ ...baseParams, issues });
227
+
228
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
229
+ expect.any(String),
230
+ expect.stringContaining('"failedPreparation":2'),
231
+ );
232
+ });
233
+
234
+ it('sets failedPreparation to 0 when no issues match the failed preparation status', async () => {
235
+ const issues = [
236
+ createIssue({ status: 'Preparation' }),
237
+ createIssue({ status: 'Awaiting workspace' }),
238
+ ];
239
+
240
+ await writeSituationFile({ ...baseParams, issues });
241
+
242
+ expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
243
+ expect.any(String),
244
+ expect.stringContaining('"failedPreparation":0'),
245
+ );
210
246
  });
211
247
  });
212
248
 
@@ -10,6 +10,7 @@ export type SituationFileParams = {
10
10
  awaitingQualityCheckStatus: string | null;
11
11
  preparationStatus: string | null;
12
12
  awaitingWorkspaceStatus: string | null;
13
+ failedPreparationStatus: string | null;
13
14
  };
14
15
  config: {
15
16
  maximumPreparingIssuesCount: number | null;
@@ -104,6 +105,12 @@ export const writeSituationFile = async (
104
105
  ? issues.filter((i) => i.status === statusNames.awaitingWorkspaceStatus)
105
106
  : [];
106
107
 
108
+ const failedPreparation =
109
+ statusNames.failedPreparationStatus !== null
110
+ ? issues.filter((i) => i.status === statusNames.failedPreparationStatus)
111
+ .length
112
+ : 0;
113
+
107
114
  const awaitingWorkspaceImmediatelyActionable = awaitingWorkspaceIssues.filter(
108
115
  isImmediatelyActionable,
109
116
  ).length;
@@ -142,6 +149,7 @@ export const writeSituationFile = async (
142
149
  preparation: preparationIssues.length,
143
150
  awaitingWorkspaceImmediatelyActionable,
144
151
  awaitingWorkspaceBlockedByDependency,
152
+ failedPreparation,
145
153
  },
146
154
  processes: {
147
155
  runningPreparation,
@@ -1,7 +1,56 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { loadTokens } from './TokenListLoader';
4
+ import { loadTokenEntries, loadTokens } from './TokenListLoader';
5
+
6
+ describe('loadTokenEntries', () => {
7
+ let tempDir: string;
8
+
9
+ beforeEach(() => {
10
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-entries-loader-'));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(tempDir, { recursive: true, force: true });
15
+ });
16
+
17
+ it('should return entries with name and token', () => {
18
+ const filePath = path.join(tempDir, 'tokens.json');
19
+ fs.writeFileSync(
20
+ filePath,
21
+ JSON.stringify([
22
+ { name: 'alice', token: 'token-a' },
23
+ { name: 'bob', token: 'token-b' },
24
+ ]),
25
+ );
26
+ expect(loadTokenEntries(filePath)).toEqual([
27
+ { name: 'alice', token: 'token-a' },
28
+ { name: 'bob', token: 'token-b' },
29
+ ]);
30
+ });
31
+
32
+ it('should assign a unique positional name when name is absent from entry', () => {
33
+ const filePath = path.join(tempDir, 'tokens.json');
34
+ fs.writeFileSync(
35
+ filePath,
36
+ JSON.stringify([{ token: 'token-a' }, { token: 'token-b' }]),
37
+ );
38
+ expect(loadTokenEntries(filePath)).toEqual([
39
+ { name: 'token-1', token: 'token-a' },
40
+ { name: 'token-2', token: 'token-b' },
41
+ ]);
42
+ });
43
+
44
+ it('should return null when file does not exist', () => {
45
+ expect(loadTokenEntries(path.join(tempDir, 'missing.json'))).toBeNull();
46
+ });
47
+
48
+ it('should return null when every entry is invalid', () => {
49
+ const filePath = path.join(tempDir, 'invalid.json');
50
+ fs.writeFileSync(filePath, JSON.stringify([{ name: 'no-token' }]));
51
+ expect(loadTokenEntries(filePath)).toBeNull();
52
+ });
53
+ });
5
54
 
6
55
  describe('TokenListLoader', () => {
7
56
  let tempDir: string;
@@ -15,21 +15,41 @@ const expandHome = (filePath: string): string => {
15
15
  const isRecord = (value: unknown): value is Record<string, unknown> =>
16
16
  value !== null && typeof value === 'object' && !Array.isArray(value);
17
17
 
18
- export const loadTokens = (jsonPath: string): string[] | null => {
18
+ export type TokenEntry = {
19
+ name: string;
20
+ token: string;
21
+ };
22
+
23
+ export const loadTokenEntries = (jsonPath: string): TokenEntry[] | null => {
19
24
  const resolved = expandHome(jsonPath);
20
25
  if (!fs.existsSync(resolved)) return null;
21
26
  try {
22
27
  const raw = fs.readFileSync(resolved, 'utf8');
23
28
  const parsed: unknown = JSON.parse(raw);
24
29
  if (!Array.isArray(parsed)) return null;
25
- const tokens: string[] = [];
30
+ const entries: TokenEntry[] = [];
26
31
  for (const entry of parsed) {
27
- if (isRecord(entry) && typeof entry.token === 'string') {
28
- tokens.push(entry.token);
32
+ if (
33
+ isRecord(entry) &&
34
+ typeof entry.token === 'string' &&
35
+ typeof entry.name === 'string'
36
+ ) {
37
+ entries.push({ name: entry.name, token: entry.token });
38
+ } else if (isRecord(entry) && typeof entry.token === 'string') {
39
+ entries.push({
40
+ name: `token-${entries.length + 1}`,
41
+ token: entry.token,
42
+ });
29
43
  }
30
44
  }
31
- return tokens.length > 0 ? tokens : null;
45
+ return entries.length > 0 ? entries : null;
32
46
  } catch {
33
47
  return null;
34
48
  }
35
49
  };
50
+
51
+ export const loadTokens = (jsonPath: string): string[] | null => {
52
+ const entries = loadTokenEntries(jsonPath);
53
+ if (entries === null) return null;
54
+ return entries.map((e) => e.token);
55
+ };