github-issue-tower-defence-management 1.44.2 → 1.44.4

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 (23) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/entry-points/cli/index.js +15 -224
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +254 -0
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -0
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +57 -5
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/repositories/OauthProxyClaudeRepository.js +5 -0
  10. package/bin/adapter/repositories/OauthProxyClaudeRepository.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/adapter/entry-points/cli/index.ts +15 -329
  13. package/src/adapter/entry-points/cli/projectConfig.ts +329 -0
  14. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +144 -5
  15. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +78 -5
  16. package/src/adapter/repositories/OauthProxyClaudeRepository.test.ts +56 -1
  17. package/src/adapter/repositories/OauthProxyClaudeRepository.ts +12 -0
  18. package/types/adapter/entry-points/cli/index.d.ts +1 -25
  19. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  20. package/types/adapter/entry-points/cli/projectConfig.d.ts +26 -0
  21. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -0
  22. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  23. package/types/adapter/repositories/OauthProxyClaudeRepository.d.ts.map +1 -1
@@ -0,0 +1,329 @@
1
+ import YAML from 'yaml';
2
+ import * as fs from 'fs';
3
+
4
+ export type ConfigFile = {
5
+ projectUrl?: string;
6
+ awaitingWorkspaceStatus?: string;
7
+ preparationStatus?: string;
8
+ defaultAgentName?: string;
9
+ defaultLlmModelName?: string;
10
+ defaultLlmAgentName?: string;
11
+ maximumPreparingIssuesCount?: number;
12
+ allowIssueCacheMinutes?: number;
13
+ utilizationPercentageThreshold?: number;
14
+ allowedIssueAuthors?: string;
15
+ awaitingQualityCheckStatus?: string;
16
+ thresholdForAutoReject?: number;
17
+ workflowBlockerResolvedWebhookUrl?: string;
18
+ projectName?: string;
19
+ preparationProcessCheckCommand?: string;
20
+ codexHomeCandidates?: string[];
21
+ awLogDirectoryPath?: string;
22
+ awLogStaleThresholdMinutes?: number;
23
+ };
24
+
25
+ const getStringValue = (
26
+ obj: Record<string, unknown>,
27
+ key: string,
28
+ ): string | undefined => {
29
+ const value = obj[key];
30
+ return typeof value === 'string' ? value : undefined;
31
+ };
32
+
33
+ const getNumberValue = (
34
+ obj: Record<string, unknown>,
35
+ key: string,
36
+ ): number | undefined => {
37
+ const value = obj[key];
38
+ return typeof value === 'number' ? value : undefined;
39
+ };
40
+
41
+ const getStringArrayValue = (
42
+ obj: Record<string, unknown>,
43
+ key: string,
44
+ ): string[] | undefined => {
45
+ const value = obj[key];
46
+ if (!Array.isArray(value)) {
47
+ return undefined;
48
+ }
49
+ const strings: string[] = [];
50
+ for (const item of value) {
51
+ if (typeof item !== 'string') {
52
+ return undefined;
53
+ }
54
+ strings.push(item);
55
+ }
56
+ return strings;
57
+ };
58
+
59
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
60
+ typeof value === 'object' && value !== null && !Array.isArray(value);
61
+
62
+ export const loadConfigFile = (configFilePath: string): ConfigFile => {
63
+ try {
64
+ const content = fs.readFileSync(configFilePath, 'utf-8');
65
+ const parsed: unknown = YAML.parse(content);
66
+ if (!isRecord(parsed)) {
67
+ return {};
68
+ }
69
+ return {
70
+ projectUrl: getStringValue(parsed, 'projectUrl'),
71
+ awaitingWorkspaceStatus: getStringValue(
72
+ parsed,
73
+ 'awaitingWorkspaceStatus',
74
+ ),
75
+ preparationStatus: getStringValue(parsed, 'preparationStatus'),
76
+ defaultAgentName: getStringValue(parsed, 'defaultAgentName'),
77
+ defaultLlmModelName: getStringValue(parsed, 'defaultLlmModelName'),
78
+ defaultLlmAgentName: getStringValue(parsed, 'defaultLlmAgentName'),
79
+ maximumPreparingIssuesCount: getNumberValue(
80
+ parsed,
81
+ 'maximumPreparingIssuesCount',
82
+ ),
83
+ allowIssueCacheMinutes: getNumberValue(parsed, 'allowIssueCacheMinutes'),
84
+ utilizationPercentageThreshold: getNumberValue(
85
+ parsed,
86
+ 'utilizationPercentageThreshold',
87
+ ),
88
+ allowedIssueAuthors: getStringValue(parsed, 'allowedIssueAuthors'),
89
+ awaitingQualityCheckStatus: getStringValue(
90
+ parsed,
91
+ 'awaitingQualityCheckStatus',
92
+ ),
93
+ thresholdForAutoReject: getNumberValue(parsed, 'thresholdForAutoReject'),
94
+ workflowBlockerResolvedWebhookUrl: getStringValue(
95
+ parsed,
96
+ 'workflowBlockerResolvedWebhookUrl',
97
+ ),
98
+ projectName: getStringValue(parsed, 'projectName'),
99
+ preparationProcessCheckCommand: getStringValue(
100
+ parsed,
101
+ 'preparationProcessCheckCommand',
102
+ ),
103
+ codexHomeCandidates: getStringArrayValue(parsed, 'codexHomeCandidates'),
104
+ awLogDirectoryPath: getStringValue(parsed, 'awLogDirectoryPath'),
105
+ awLogStaleThresholdMinutes: getNumberValue(
106
+ parsed,
107
+ 'awLogStaleThresholdMinutes',
108
+ ),
109
+ };
110
+ } catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ console.error(
113
+ `Failed to load configuration file "${configFilePath}": ${message}`,
114
+ );
115
+ process.exit(1);
116
+ }
117
+ };
118
+
119
+ export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
120
+ const detailsRegex =
121
+ /<details>\s*<summary>config<\/summary>([\s\S]*?)<\/details>/i;
122
+ const match = detailsRegex.exec(readme);
123
+ if (!match) {
124
+ return {};
125
+ }
126
+ const yamlContent = match[1].trim();
127
+ if (!yamlContent) {
128
+ return {};
129
+ }
130
+ try {
131
+ const parsed: unknown = YAML.parse(yamlContent);
132
+ if (!isRecord(parsed)) {
133
+ return {};
134
+ }
135
+ return {
136
+ awaitingWorkspaceStatus: getStringValue(
137
+ parsed,
138
+ 'awaitingWorkspaceStatus',
139
+ ),
140
+ preparationStatus: getStringValue(parsed, 'preparationStatus'),
141
+ defaultAgentName: getStringValue(parsed, 'defaultAgentName'),
142
+ defaultLlmModelName: getStringValue(parsed, 'defaultLlmModelName'),
143
+ defaultLlmAgentName: getStringValue(parsed, 'defaultLlmAgentName'),
144
+ maximumPreparingIssuesCount: getNumberValue(
145
+ parsed,
146
+ 'maximumPreparingIssuesCount',
147
+ ),
148
+ allowIssueCacheMinutes: getNumberValue(parsed, 'allowIssueCacheMinutes'),
149
+ utilizationPercentageThreshold: getNumberValue(
150
+ parsed,
151
+ 'utilizationPercentageThreshold',
152
+ ),
153
+ allowedIssueAuthors: getStringValue(parsed, 'allowedIssueAuthors'),
154
+ awaitingQualityCheckStatus: getStringValue(
155
+ parsed,
156
+ 'awaitingQualityCheckStatus',
157
+ ),
158
+ thresholdForAutoReject: getNumberValue(parsed, 'thresholdForAutoReject'),
159
+ workflowBlockerResolvedWebhookUrl: getStringValue(
160
+ parsed,
161
+ 'workflowBlockerResolvedWebhookUrl',
162
+ ),
163
+ preparationProcessCheckCommand: getStringValue(
164
+ parsed,
165
+ 'preparationProcessCheckCommand',
166
+ ),
167
+ codexHomeCandidates: getStringArrayValue(parsed, 'codexHomeCandidates'),
168
+ awLogDirectoryPath: getStringValue(parsed, 'awLogDirectoryPath'),
169
+ awLogStaleThresholdMinutes: getNumberValue(
170
+ parsed,
171
+ 'awLogStaleThresholdMinutes',
172
+ ),
173
+ };
174
+ } catch {
175
+ console.warn('Failed to parse YAML from project README config section');
176
+ return {};
177
+ }
178
+ };
179
+
180
+ export const mergeConfigs = (
181
+ configFile: ConfigFile,
182
+ cliOverrides: ConfigFile,
183
+ readmeOverrides: ConfigFile,
184
+ ): ConfigFile => ({
185
+ projectUrl: cliOverrides.projectUrl ?? configFile.projectUrl,
186
+ awaitingWorkspaceStatus:
187
+ readmeOverrides.awaitingWorkspaceStatus ??
188
+ cliOverrides.awaitingWorkspaceStatus ??
189
+ configFile.awaitingWorkspaceStatus,
190
+ preparationStatus:
191
+ readmeOverrides.preparationStatus ??
192
+ cliOverrides.preparationStatus ??
193
+ configFile.preparationStatus,
194
+ defaultAgentName:
195
+ readmeOverrides.defaultAgentName ??
196
+ cliOverrides.defaultAgentName ??
197
+ configFile.defaultAgentName,
198
+ defaultLlmModelName:
199
+ readmeOverrides.defaultLlmModelName ??
200
+ cliOverrides.defaultLlmModelName ??
201
+ configFile.defaultLlmModelName,
202
+ defaultLlmAgentName:
203
+ readmeOverrides.defaultLlmAgentName ??
204
+ cliOverrides.defaultLlmAgentName ??
205
+ configFile.defaultLlmAgentName,
206
+ maximumPreparingIssuesCount:
207
+ readmeOverrides.maximumPreparingIssuesCount ??
208
+ cliOverrides.maximumPreparingIssuesCount ??
209
+ configFile.maximumPreparingIssuesCount,
210
+ allowIssueCacheMinutes:
211
+ readmeOverrides.allowIssueCacheMinutes ??
212
+ cliOverrides.allowIssueCacheMinutes ??
213
+ configFile.allowIssueCacheMinutes,
214
+ utilizationPercentageThreshold:
215
+ readmeOverrides.utilizationPercentageThreshold ??
216
+ cliOverrides.utilizationPercentageThreshold ??
217
+ configFile.utilizationPercentageThreshold,
218
+ allowedIssueAuthors:
219
+ readmeOverrides.allowedIssueAuthors ??
220
+ cliOverrides.allowedIssueAuthors ??
221
+ configFile.allowedIssueAuthors,
222
+ awaitingQualityCheckStatus:
223
+ readmeOverrides.awaitingQualityCheckStatus ??
224
+ cliOverrides.awaitingQualityCheckStatus ??
225
+ configFile.awaitingQualityCheckStatus,
226
+ thresholdForAutoReject:
227
+ readmeOverrides.thresholdForAutoReject ??
228
+ cliOverrides.thresholdForAutoReject ??
229
+ configFile.thresholdForAutoReject,
230
+ workflowBlockerResolvedWebhookUrl:
231
+ readmeOverrides.workflowBlockerResolvedWebhookUrl ??
232
+ cliOverrides.workflowBlockerResolvedWebhookUrl ??
233
+ configFile.workflowBlockerResolvedWebhookUrl,
234
+ projectName: configFile.projectName,
235
+ preparationProcessCheckCommand:
236
+ readmeOverrides.preparationProcessCheckCommand ??
237
+ cliOverrides.preparationProcessCheckCommand ??
238
+ configFile.preparationProcessCheckCommand,
239
+ codexHomeCandidates:
240
+ readmeOverrides.codexHomeCandidates ??
241
+ cliOverrides.codexHomeCandidates ??
242
+ configFile.codexHomeCandidates,
243
+ awLogDirectoryPath:
244
+ readmeOverrides.awLogDirectoryPath ??
245
+ cliOverrides.awLogDirectoryPath ??
246
+ configFile.awLogDirectoryPath,
247
+ awLogStaleThresholdMinutes:
248
+ readmeOverrides.awLogStaleThresholdMinutes ??
249
+ cliOverrides.awLogStaleThresholdMinutes ??
250
+ configFile.awLogStaleThresholdMinutes,
251
+ });
252
+
253
+ type GraphqlProjectV2ReadmeResponse = {
254
+ data?: {
255
+ organization?: { projectV2?: { readme?: string | null } };
256
+ user?: { projectV2?: { readme?: string | null } };
257
+ };
258
+ };
259
+
260
+ const isGraphqlProjectV2ReadmeResponse = (
261
+ value: unknown,
262
+ ): value is GraphqlProjectV2ReadmeResponse => {
263
+ if (!isRecord(value)) return false;
264
+ const data = value['data'];
265
+ if (data !== undefined && !isRecord(data)) return false;
266
+ return true;
267
+ };
268
+
269
+ export const fetchProjectReadme = async (
270
+ projectUrl: string,
271
+ token: string,
272
+ ): Promise<string | null> => {
273
+ try {
274
+ const urlParts = projectUrl.split('/');
275
+ const projectNumber = parseInt(urlParts[urlParts.length - 1], 10);
276
+ const owner = urlParts[urlParts.length - 3];
277
+
278
+ const query = `
279
+ query($owner: String!, $number: Int!) {
280
+ organization(login: $owner) {
281
+ projectV2(number: $number) {
282
+ readme
283
+ }
284
+ }
285
+ user(login: $owner) {
286
+ projectV2(number: $number) {
287
+ readme
288
+ }
289
+ }
290
+ }
291
+ `;
292
+
293
+ const response = await fetch('https://api.github.com/graphql', {
294
+ method: 'POST',
295
+ headers: {
296
+ Authorization: `Bearer ${token}`,
297
+ 'Content-Type': 'application/json',
298
+ },
299
+ body: JSON.stringify({
300
+ query,
301
+ variables: { owner, number: projectNumber },
302
+ }),
303
+ });
304
+
305
+ if (!response.ok) {
306
+ throw new Error(`GraphQL request failed: ${response.status}`);
307
+ }
308
+
309
+ const responseData: unknown = await response.json();
310
+
311
+ if (!isGraphqlProjectV2ReadmeResponse(responseData)) {
312
+ return null;
313
+ }
314
+
315
+ const orgReadme = responseData.data?.organization?.projectV2?.readme;
316
+ const userReadme = responseData.data?.user?.projectV2?.readme;
317
+ const readme =
318
+ typeof orgReadme === 'string'
319
+ ? orgReadme
320
+ : typeof userReadme === 'string'
321
+ ? userReadme
322
+ : null;
323
+
324
+ return readme;
325
+ } catch {
326
+ console.warn('Failed to fetch project README');
327
+ return null;
328
+ }
329
+ };
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import YAML from 'yaml';
3
+ import type { HandleScheduledEventUseCase } from '../../../domain/usecases/HandleScheduledEventUseCase';
3
4
 
4
5
  jest.mock('fs');
5
6
  jest.mock('gh-cookie', () => ({ getCookieContent: jest.fn() }));
@@ -15,11 +16,16 @@ jest.mock('../../repositories/issue/ApiV3CheerioRestIssueRepository');
15
16
  jest.mock('../../repositories/LocalStorageCacheRepository');
16
17
  jest.mock('../../repositories/BaseGitHubRepository');
17
18
 
18
- const mockRun = jest.fn().mockResolvedValue({
19
- project: { id: 'PVT_kwHOtest123' },
20
- issues: [],
21
- cacheUsed: false,
22
- targetDateTimes: [],
19
+ type RunFn = HandleScheduledEventUseCase['run'];
20
+ const capturedRunInputs: Parameters<RunFn>[] = [];
21
+ const mockRun = jest.fn().mockImplementation((...args: Parameters<RunFn>) => {
22
+ capturedRunInputs.push(args);
23
+ return Promise.resolve({
24
+ project: { id: 'PVT_kwHOtest123' },
25
+ issues: [],
26
+ cacheUsed: false,
27
+ targetDateTimes: [],
28
+ });
23
29
  });
24
30
 
25
31
  jest.mock('../../../domain/usecases/HandleScheduledEventUseCase', () => ({
@@ -158,10 +164,25 @@ const validConfig = {
158
164
  },
159
165
  };
160
166
 
167
+ const mockFetchReturningReadme = (readme: string | null): void => {
168
+ const responseBody =
169
+ readme === null
170
+ ? { data: {} }
171
+ : { data: { organization: { projectV2: { readme } } } };
172
+ jest.spyOn(global, 'fetch').mockResolvedValue(
173
+ new Response(JSON.stringify(responseBody), {
174
+ status: 200,
175
+ headers: { 'Content-Type': 'application/json' },
176
+ }),
177
+ );
178
+ };
179
+
161
180
  describe('HandleScheduledEventUseCaseHandler', () => {
162
181
  beforeEach(() => {
163
182
  jest.clearAllMocks();
183
+ capturedRunInputs.length = 0;
164
184
  jest.mocked(fs.readFileSync).mockReturnValue(YAML.stringify(validConfig));
185
+ mockFetchReturningReadme(null);
165
186
  });
166
187
 
167
188
  it('should pass bot credentials to repository constructors when provided', async () => {
@@ -330,4 +351,122 @@ describe('HandleScheduledEventUseCaseHandler', () => {
330
351
  expect(jest.mocked(fs.writeFileSync)).not.toHaveBeenCalled();
331
352
  expect(jest.mocked(fs.renameSync)).not.toHaveBeenCalled();
332
353
  });
354
+
355
+ describe('README config overrides', () => {
356
+ const configWithStartPreparation = {
357
+ ...validConfig,
358
+ allowIssueCacheMinutes: 5,
359
+ startPreparation: {
360
+ awaitingWorkspaceStatus: 'Awaiting workspace',
361
+ preparationStatus: 'Preparation',
362
+ defaultAgentName: 'yaml-agent',
363
+ configFilePath: '/path/to/config.yml',
364
+ maximumPreparingIssuesCount: 10,
365
+ utilizationPercentageThreshold: 90,
366
+ },
367
+ };
368
+
369
+ it('should override startPreparation fields from README config', async () => {
370
+ const readmeContent = `<details>
371
+ <summary>config</summary>
372
+ maximumPreparingIssuesCount: 0
373
+ awaitingWorkspaceStatus: README Awaiting
374
+ preparationStatus: README Preparation
375
+ defaultAgentName: readme-agent
376
+ utilizationPercentageThreshold: 80
377
+ </details>`;
378
+ mockFetchReturningReadme(readmeContent);
379
+ jest
380
+ .mocked(fs.readFileSync)
381
+ .mockReturnValue(YAML.stringify(configWithStartPreparation));
382
+
383
+ const handler = new HandleScheduledEventUseCaseHandler();
384
+ await handler.handle('config.yml', false);
385
+
386
+ expect(capturedRunInputs[0][0]).toMatchObject({
387
+ startPreparation: {
388
+ maximumPreparingIssuesCount: 0,
389
+ awaitingWorkspaceStatus: 'README Awaiting',
390
+ preparationStatus: 'README Preparation',
391
+ defaultAgentName: 'readme-agent',
392
+ utilizationPercentageThreshold: 80,
393
+ },
394
+ });
395
+ });
396
+
397
+ it('should override allowIssueCacheMinutes from README config', async () => {
398
+ const readmeContent = `<details>
399
+ <summary>config</summary>
400
+ allowIssueCacheMinutes: 30
401
+ </details>`;
402
+ mockFetchReturningReadme(readmeContent);
403
+ jest
404
+ .mocked(fs.readFileSync)
405
+ .mockReturnValue(YAML.stringify(configWithStartPreparation));
406
+
407
+ const handler = new HandleScheduledEventUseCaseHandler();
408
+ await handler.handle('config.yml', false);
409
+
410
+ expect(mockRun).toHaveBeenCalledWith(
411
+ expect.objectContaining({
412
+ allowIssueCacheMinutes: 30,
413
+ }),
414
+ );
415
+ });
416
+
417
+ it('should split comma-separated allowedIssueAuthors from README config', async () => {
418
+ const readmeContent = `<details>
419
+ <summary>config</summary>
420
+ allowedIssueAuthors: 'user1, user2, user3'
421
+ </details>`;
422
+ mockFetchReturningReadme(readmeContent);
423
+ jest
424
+ .mocked(fs.readFileSync)
425
+ .mockReturnValue(YAML.stringify(configWithStartPreparation));
426
+
427
+ const handler = new HandleScheduledEventUseCaseHandler();
428
+ await handler.handle('config.yml', false);
429
+
430
+ expect(capturedRunInputs[0][0]).toMatchObject({
431
+ startPreparation: {
432
+ allowedIssueAuthors: ['user1', 'user2', 'user3'],
433
+ },
434
+ });
435
+ });
436
+
437
+ it('should keep YAML values when README has no config section', async () => {
438
+ mockFetchReturningReadme(null);
439
+ jest
440
+ .mocked(fs.readFileSync)
441
+ .mockReturnValue(YAML.stringify(configWithStartPreparation));
442
+
443
+ const handler = new HandleScheduledEventUseCaseHandler();
444
+ await handler.handle('config.yml', false);
445
+
446
+ expect(capturedRunInputs[0][0]).toMatchObject({
447
+ allowIssueCacheMinutes: 5,
448
+ startPreparation: {
449
+ maximumPreparingIssuesCount: 10,
450
+ awaitingWorkspaceStatus: 'Awaiting workspace',
451
+ defaultAgentName: 'yaml-agent',
452
+ },
453
+ });
454
+ });
455
+
456
+ it('should use README token from manager credentials to fetch README', async () => {
457
+ mockFetchReturningReadme(null);
458
+ jest.mocked(fs.readFileSync).mockReturnValue(YAML.stringify(validConfig));
459
+
460
+ const handler = new HandleScheduledEventUseCaseHandler();
461
+ await handler.handle('config.yml', false);
462
+
463
+ expect(global.fetch).toHaveBeenCalledWith(
464
+ 'https://api.github.com/graphql',
465
+ expect.objectContaining({ method: 'POST' }),
466
+ );
467
+ expect(capturedRunInputs[0][0]).toMatchObject({
468
+ projectUrl: validConfig.projectUrl,
469
+ });
470
+ });
471
+ });
333
472
  });
@@ -1,6 +1,10 @@
1
1
  import YAML from 'yaml';
2
2
  import TYPIA from 'typia';
3
3
  import fs from 'fs';
4
+ import {
5
+ fetchProjectReadme,
6
+ parseProjectReadmeConfig,
7
+ } from '../cli/projectConfig';
4
8
  import { SystemDateRepository } from '../../repositories/SystemDateRepository';
5
9
  import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
6
10
  import { GoogleSpreadsheetRepository } from '../../repositories/GoogleSpreadsheetRepository';
@@ -81,6 +85,75 @@ export class HandleScheduledEventUseCaseHandler {
81
85
  if (input.disabled) {
82
86
  return null;
83
87
  }
88
+
89
+ const managerToken = input.credentials.manager.github.token;
90
+ const readme = await fetchProjectReadme(input.projectUrl, managerToken);
91
+ const readmeConfig = readme ? parseProjectReadmeConfig(readme) : {};
92
+
93
+ const mergedInput = {
94
+ ...input,
95
+ allowIssueCacheMinutes:
96
+ readmeConfig.allowIssueCacheMinutes ?? input.allowIssueCacheMinutes,
97
+ startPreparation: input.startPreparation
98
+ ? {
99
+ ...input.startPreparation,
100
+ awaitingWorkspaceStatus:
101
+ readmeConfig.awaitingWorkspaceStatus ??
102
+ input.startPreparation.awaitingWorkspaceStatus,
103
+ preparationStatus:
104
+ readmeConfig.preparationStatus ??
105
+ input.startPreparation.preparationStatus,
106
+ defaultAgentName:
107
+ readmeConfig.defaultAgentName ??
108
+ input.startPreparation.defaultAgentName,
109
+ defaultLlmModelName:
110
+ readmeConfig.defaultLlmModelName ??
111
+ input.startPreparation.defaultLlmModelName,
112
+ defaultLlmAgentName:
113
+ readmeConfig.defaultLlmAgentName ??
114
+ input.startPreparation.defaultLlmAgentName,
115
+ maximumPreparingIssuesCount:
116
+ readmeConfig.maximumPreparingIssuesCount ??
117
+ input.startPreparation.maximumPreparingIssuesCount,
118
+ utilizationPercentageThreshold:
119
+ readmeConfig.utilizationPercentageThreshold ??
120
+ input.startPreparation.utilizationPercentageThreshold,
121
+ allowedIssueAuthors: readmeConfig.allowedIssueAuthors
122
+ ? readmeConfig.allowedIssueAuthors
123
+ .split(',')
124
+ .map((s) => s.trim())
125
+ .filter(Boolean)
126
+ : input.startPreparation.allowedIssueAuthors,
127
+ preparationProcessCheckCommand:
128
+ readmeConfig.preparationProcessCheckCommand ??
129
+ input.startPreparation.preparationProcessCheckCommand,
130
+ codexHomeCandidates:
131
+ readmeConfig.codexHomeCandidates ??
132
+ input.startPreparation.codexHomeCandidates,
133
+ }
134
+ : input.startPreparation,
135
+ notifyFinishedPreparation: input.notifyFinishedPreparation
136
+ ? {
137
+ ...input.notifyFinishedPreparation,
138
+ awaitingWorkspaceStatus:
139
+ readmeConfig.awaitingWorkspaceStatus ??
140
+ input.notifyFinishedPreparation.awaitingWorkspaceStatus,
141
+ preparationStatus:
142
+ readmeConfig.preparationStatus ??
143
+ input.notifyFinishedPreparation.preparationStatus,
144
+ awaitingQualityCheckStatus:
145
+ readmeConfig.awaitingQualityCheckStatus ??
146
+ input.notifyFinishedPreparation.awaitingQualityCheckStatus,
147
+ thresholdForAutoReject:
148
+ readmeConfig.thresholdForAutoReject ??
149
+ input.notifyFinishedPreparation.thresholdForAutoReject,
150
+ workflowBlockerResolvedWebhookUrl:
151
+ readmeConfig.workflowBlockerResolvedWebhookUrl ??
152
+ input.notifyFinishedPreparation.workflowBlockerResolvedWebhookUrl,
153
+ }
154
+ : input.notifyFinishedPreparation,
155
+ };
156
+
84
157
  const systemDateRepository = new SystemDateRepository();
85
158
  const localStorageRepository = new LocalStorageRepository();
86
159
  const googleSpreadsheetRepository = new GoogleSpreadsheetRepository(
@@ -217,18 +290,18 @@ export class HandleScheduledEventUseCaseHandler {
217
290
  issueRepository,
218
291
  );
219
292
 
220
- const result = await handleScheduledEventUseCase.run(input);
293
+ const result = await handleScheduledEventUseCase.run(mergedInput);
221
294
  if (result) {
222
295
  const projectId = result.project.id;
223
296
  const runtimeConfig = {
224
297
  resolvedAt: new Date().toISOString(),
225
298
  maximumPreparingIssuesCount:
226
- input.startPreparation?.maximumPreparingIssuesCount ?? null,
299
+ mergedInput.startPreparation?.maximumPreparingIssuesCount ?? null,
227
300
  utilizationPercentageThreshold:
228
- input.startPreparation?.utilizationPercentageThreshold ?? 90,
229
- allowIssueCacheMinutes: input.allowIssueCacheMinutes,
301
+ mergedInput.startPreparation?.utilizationPercentageThreshold ?? 90,
302
+ allowIssueCacheMinutes: mergedInput.allowIssueCacheMinutes,
230
303
  thresholdForAutoReject:
231
- input.notifyFinishedPreparation?.thresholdForAutoReject ?? 3,
304
+ mergedInput.notifyFinishedPreparation?.thresholdForAutoReject ?? 3,
232
305
  };
233
306
  const finalPath = `${cachePath}/runtimeConfig-${projectId}.json`;
234
307
  const tmpPath = `${finalPath}.tmp`;
@@ -117,7 +117,7 @@ describe('OauthProxyClaudeRepository', () => {
117
117
  ],
118
118
  },
119
119
  {
120
- name: 'synthesises hour:168 100% entry when overage-status is rejected and no existing entry is at 100%',
120
+ name: 'synthesises hour:168 100% entry when overage-status is rejected with out_of_credits reason and no existing entry is at 100%',
121
121
  fileExists: true,
122
122
  fileContent: JSON.stringify({
123
123
  headers: {
@@ -142,6 +142,61 @@ describe('OauthProxyClaudeRepository', () => {
142
142
  },
143
143
  ],
144
144
  },
145
+ {
146
+ name: 'synthesises hour:168 100% entry when overage-status is rejected without disabled-reason header',
147
+ fileExists: true,
148
+ fileContent: JSON.stringify({
149
+ headers: {
150
+ 'anthropic-ratelimit-unified-7d-utilization': '0.88',
151
+ 'anthropic-ratelimit-unified-7d-reset': '1772769600',
152
+ 'anthropic-ratelimit-unified-overage-status': 'rejected',
153
+ },
154
+ ts: 1234567890,
155
+ }),
156
+ expected: [
157
+ {
158
+ hour: 168,
159
+ utilizationPercentage: 88,
160
+ resetsAt: new Date(1772769600 * 1000),
161
+ },
162
+ {
163
+ hour: 168,
164
+ utilizationPercentage: 100,
165
+ resetsAt: new Date(1772769600 * 1000),
166
+ },
167
+ ],
168
+ },
169
+ {
170
+ name: 'does not synthesise entry when overage-status is rejected but overage-disabled-reason is org_level_disabled_until',
171
+ fileExists: true,
172
+ fileContent: JSON.stringify({
173
+ headers: {
174
+ 'anthropic-ratelimit-unified-status': 'allowed',
175
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
176
+ 'anthropic-ratelimit-unified-5h-utilization': '0.10',
177
+ 'anthropic-ratelimit-unified-5h-reset': '1772575200',
178
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
179
+ 'anthropic-ratelimit-unified-7d-utilization': '0.05',
180
+ 'anthropic-ratelimit-unified-7d-reset': '1772769600',
181
+ 'anthropic-ratelimit-unified-overage-status': 'rejected',
182
+ 'anthropic-ratelimit-unified-overage-disabled-reason':
183
+ 'org_level_disabled_until',
184
+ },
185
+ ts: 1234567890,
186
+ }),
187
+ expected: [
188
+ {
189
+ hour: 5,
190
+ utilizationPercentage: 10,
191
+ resetsAt: new Date(1772575200 * 1000),
192
+ },
193
+ {
194
+ hour: 168,
195
+ utilizationPercentage: 5,
196
+ resetsAt: new Date(1772769600 * 1000),
197
+ },
198
+ ],
199
+ },
145
200
  {
146
201
  name: 'does not synthesise entry when overage-status is absent',
147
202
  fileExists: true,