github-issue-tower-defence-management 1.80.1 → 1.82.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 +22 -0
- package/README.md +5 -2
- package/bin/adapter/entry-points/cli/index.js +1 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +20 -0
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +48 -20
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/proxy/RateLimitCache.js +32 -4
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/proxy/proxyEntry.js +1 -1
- package/bin/adapter/proxy/proxyEntry.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +3 -0
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
- package/bin/domain/usecases/ChangeTargetPullRequestApprover.js +9 -5
- package/bin/domain/usecases/ChangeTargetPullRequestApprover.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +8 -0
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +18 -0
- package/src/adapter/entry-points/cli/index.ts +1 -0
- package/src/adapter/entry-points/cli/projectConfig.ts +32 -0
- package/src/adapter/entry-points/handlers/rotationOrderFileWriter.test.ts +4 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +103 -0
- package/src/adapter/proxy/RateLimitCache.ts +44 -2
- package/src/adapter/proxy/proxyEntry.test.ts +17 -0
- package/src/adapter/proxy/proxyEntry.ts +5 -1
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +14 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +3 -0
- package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
- package/src/domain/usecases/ChangeTargetPullRequestApprover.test.ts +78 -0
- package/src/domain/usecases/ChangeTargetPullRequestApprover.ts +14 -4
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -0
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +18 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +1 -1
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +61 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +2 -0
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +36 -0
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +2 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +190 -0
- package/src/domain/usecases/StartPreparationUseCase.ts +14 -0
- package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
- package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
- package/types/adapter/proxy/RateLimitCache.d.ts +4 -1
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
- package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
- package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
- package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts +1 -1
- package/types/domain/usecases/ChangeTargetPullRequestApprover.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +1 -0
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +1 -0
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +2 -0
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
|
@@ -20,6 +20,7 @@ export type ConfigFile = {
|
|
|
20
20
|
awLogDirectoryPath?: string;
|
|
21
21
|
awLogStaleThresholdMinutes?: number;
|
|
22
22
|
labelsAsLlmAgentName?: string[];
|
|
23
|
+
changeTargetPathAliases?: Record<string, string>;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const getStringValue = (
|
|
@@ -38,6 +39,24 @@ const getNumberValue = (
|
|
|
38
39
|
return typeof value === 'number' ? value : undefined;
|
|
39
40
|
};
|
|
40
41
|
|
|
42
|
+
const getStringRecordValue = (
|
|
43
|
+
obj: Record<string, unknown>,
|
|
44
|
+
key: string,
|
|
45
|
+
): Record<string, string> | undefined => {
|
|
46
|
+
const value = obj[key];
|
|
47
|
+
if (!isRecord(value)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const result: Record<string, string> = {};
|
|
51
|
+
for (const [k, v] of Object.entries(value)) {
|
|
52
|
+
if (typeof v !== 'string') {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
result[k] = v;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
};
|
|
59
|
+
|
|
41
60
|
const getStringArrayValue = (
|
|
42
61
|
obj: Record<string, unknown>,
|
|
43
62
|
key: string,
|
|
@@ -75,6 +94,7 @@ const knownProjectReadmeConfigKeys = [
|
|
|
75
94
|
'claudeCodeOauthTokenListJsonPath',
|
|
76
95
|
'awLogDirectoryPath',
|
|
77
96
|
'awLogStaleThresholdMinutes',
|
|
97
|
+
'changeTargetPathAliases',
|
|
78
98
|
] as const;
|
|
79
99
|
|
|
80
100
|
export const loadConfigFile = (configFilePath: string): ConfigFile => {
|
|
@@ -121,6 +141,10 @@ export const loadConfigFile = (configFilePath: string): ConfigFile => {
|
|
|
121
141
|
'awLogStaleThresholdMinutes',
|
|
122
142
|
),
|
|
123
143
|
labelsAsLlmAgentName: getStringArrayValue(parsed, 'labelsAsLlmAgentName'),
|
|
144
|
+
changeTargetPathAliases: getStringRecordValue(
|
|
145
|
+
parsed,
|
|
146
|
+
'changeTargetPathAliases',
|
|
147
|
+
),
|
|
124
148
|
};
|
|
125
149
|
} catch (error) {
|
|
126
150
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -193,6 +217,10 @@ export const parseProjectReadmeConfig = (
|
|
|
193
217
|
parsed,
|
|
194
218
|
'awLogStaleThresholdMinutes',
|
|
195
219
|
),
|
|
220
|
+
changeTargetPathAliases: getStringRecordValue(
|
|
221
|
+
parsed,
|
|
222
|
+
'changeTargetPathAliases',
|
|
223
|
+
),
|
|
196
224
|
};
|
|
197
225
|
} catch {
|
|
198
226
|
console.warn('Failed to parse YAML from project README config section');
|
|
@@ -271,6 +299,10 @@ export const mergeConfigs = (
|
|
|
271
299
|
readmeOverrides.labelsAsLlmAgentName ??
|
|
272
300
|
cliOverrides.labelsAsLlmAgentName ??
|
|
273
301
|
configFile.labelsAsLlmAgentName,
|
|
302
|
+
changeTargetPathAliases:
|
|
303
|
+
readmeOverrides.changeTargetPathAliases ??
|
|
304
|
+
cliOverrides.changeTargetPathAliases ??
|
|
305
|
+
configFile.changeTargetPathAliases,
|
|
274
306
|
});
|
|
275
307
|
|
|
276
308
|
type GraphqlProjectV2ReadmeResponse = {
|
|
@@ -28,6 +28,7 @@ describe('writeRotationOrderFile', () => {
|
|
|
28
28
|
blocked: false,
|
|
29
29
|
rejected: false,
|
|
30
30
|
thresholdExcluded: false,
|
|
31
|
+
cooldownExcluded: false,
|
|
31
32
|
},
|
|
32
33
|
];
|
|
33
34
|
|
|
@@ -77,6 +78,7 @@ describe('writeRotationOrderFile', () => {
|
|
|
77
78
|
blocked: false,
|
|
78
79
|
rejected: false,
|
|
79
80
|
thresholdExcluded: false,
|
|
81
|
+
cooldownExcluded: false,
|
|
80
82
|
},
|
|
81
83
|
{
|
|
82
84
|
name: 'personal-2',
|
|
@@ -84,6 +86,7 @@ describe('writeRotationOrderFile', () => {
|
|
|
84
86
|
blocked: false,
|
|
85
87
|
rejected: false,
|
|
86
88
|
thresholdExcluded: true,
|
|
89
|
+
cooldownExcluded: false,
|
|
87
90
|
},
|
|
88
91
|
];
|
|
89
92
|
|
|
@@ -119,6 +122,7 @@ describe('writeRotationOrderFile', () => {
|
|
|
119
122
|
blocked: false,
|
|
120
123
|
rejected: false,
|
|
121
124
|
thresholdExcluded: false,
|
|
125
|
+
cooldownExcluded: false,
|
|
122
126
|
},
|
|
123
127
|
];
|
|
124
128
|
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
cacheDir,
|
|
6
6
|
cachePathForToken,
|
|
7
7
|
hashToken,
|
|
8
|
+
HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
|
|
9
|
+
HEADERLESS_429_MAX_COOLDOWN_SECONDS,
|
|
8
10
|
parseModelRateLimitsFromBody,
|
|
9
11
|
readRateLimit,
|
|
10
12
|
writeModelRateLimit,
|
|
@@ -446,6 +448,107 @@ describe('RateLimitCache', () => {
|
|
|
446
448
|
});
|
|
447
449
|
});
|
|
448
450
|
|
|
451
|
+
describe('writeRateLimit records a cooldown on a 429 with no anthropic-ratelimit-* headers', () => {
|
|
452
|
+
it('should set blockedUntilEpoch using the default cooldown when no Retry-After header is present', () => {
|
|
453
|
+
const token = '429-no-headers-default-cooldown-token';
|
|
454
|
+
const before = Date.now() / 1000;
|
|
455
|
+
writeRateLimit(token, { 'content-type': 'application/json' }, 429);
|
|
456
|
+
const after = Date.now() / 1000;
|
|
457
|
+
const snapshot = readRateLimit(token);
|
|
458
|
+
expect(snapshot).not.toBeNull();
|
|
459
|
+
expect(snapshot?.blockedUntilEpoch).toBeGreaterThanOrEqual(
|
|
460
|
+
before + HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
|
|
461
|
+
);
|
|
462
|
+
expect(snapshot?.blockedUntilEpoch).toBeLessThanOrEqual(
|
|
463
|
+
after + HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should honor the Retry-After header when present', () => {
|
|
468
|
+
const token = '429-no-headers-retry-after-token';
|
|
469
|
+
const retryAfterSeconds = 120;
|
|
470
|
+
const before = Date.now() / 1000;
|
|
471
|
+
writeRateLimit(
|
|
472
|
+
token,
|
|
473
|
+
{ 'content-type': 'application/json', 'retry-after': '120' },
|
|
474
|
+
429,
|
|
475
|
+
);
|
|
476
|
+
const after = Date.now() / 1000;
|
|
477
|
+
const snapshot = readRateLimit(token);
|
|
478
|
+
expect(snapshot?.blockedUntilEpoch).toBeGreaterThanOrEqual(
|
|
479
|
+
before + retryAfterSeconds,
|
|
480
|
+
);
|
|
481
|
+
expect(snapshot?.blockedUntilEpoch).toBeLessThanOrEqual(
|
|
482
|
+
after + retryAfterSeconds,
|
|
483
|
+
);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should clamp the cooldown to the maximum when Retry-After is very large', () => {
|
|
487
|
+
const token = '429-no-headers-clamp-token';
|
|
488
|
+
const before = Date.now() / 1000;
|
|
489
|
+
writeRateLimit(
|
|
490
|
+
token,
|
|
491
|
+
{ 'content-type': 'application/json', 'retry-after': '99999' },
|
|
492
|
+
429,
|
|
493
|
+
);
|
|
494
|
+
const after = Date.now() / 1000;
|
|
495
|
+
const snapshot = readRateLimit(token);
|
|
496
|
+
expect(snapshot?.blockedUntilEpoch).toBeGreaterThanOrEqual(
|
|
497
|
+
before + HEADERLESS_429_MAX_COOLDOWN_SECONDS,
|
|
498
|
+
);
|
|
499
|
+
expect(snapshot?.blockedUntilEpoch).toBeLessThanOrEqual(
|
|
500
|
+
after + HEADERLESS_429_MAX_COOLDOWN_SECONDS,
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('should preserve the previous last-good snapshot while adding the cooldown', () => {
|
|
505
|
+
const token = '429-no-headers-preserve-snapshot-token';
|
|
506
|
+
writeRateLimit(token, {
|
|
507
|
+
'anthropic-ratelimit-unified-status': 'allowed',
|
|
508
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
509
|
+
'anthropic-ratelimit-unified-5h-reset': '1700000000',
|
|
510
|
+
'anthropic-ratelimit-unified-5h-utilization': '42',
|
|
511
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
512
|
+
'anthropic-ratelimit-unified-7d-reset': '1700100000',
|
|
513
|
+
'anthropic-ratelimit-unified-7d-utilization': '17',
|
|
514
|
+
});
|
|
515
|
+
writeRateLimit(token, { 'content-type': 'application/json' }, 429);
|
|
516
|
+
const snapshot = readRateLimit(token);
|
|
517
|
+
expect(snapshot?.fiveHourUtilization).toBe(42);
|
|
518
|
+
expect(snapshot?.fiveHourReset).toBe(1700000000);
|
|
519
|
+
expect(snapshot?.sevenDayUtilization).toBe(17);
|
|
520
|
+
expect(snapshot?.sevenDayReset).toBe(1700100000);
|
|
521
|
+
expect(snapshot?.rejected).toBe(false);
|
|
522
|
+
expect(snapshot?.blocked).toBe(false);
|
|
523
|
+
expect(snapshot?.blockedUntilEpoch).toBeGreaterThan(Date.now() / 1000);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should clear the cooldown when a later header-bearing response arrives', () => {
|
|
527
|
+
const token = '429-then-normal-clears-cooldown-token';
|
|
528
|
+
writeRateLimit(token, { 'content-type': 'application/json' }, 429);
|
|
529
|
+
expect(readRateLimit(token)?.blockedUntilEpoch).toBeGreaterThan(
|
|
530
|
+
Date.now() / 1000,
|
|
531
|
+
);
|
|
532
|
+
writeRateLimit(token, {
|
|
533
|
+
'anthropic-ratelimit-unified-status': 'allowed',
|
|
534
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
535
|
+
'anthropic-ratelimit-unified-5h-reset': '1700000000',
|
|
536
|
+
'anthropic-ratelimit-unified-5h-utilization': '30',
|
|
537
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
538
|
+
'anthropic-ratelimit-unified-7d-reset': '1700100000',
|
|
539
|
+
'anthropic-ratelimit-unified-7d-utilization': '20',
|
|
540
|
+
});
|
|
541
|
+
expect(readRateLimit(token)?.blockedUntilEpoch).toBe(0);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should not write any cooldown for a headerless response that is not a 429', () => {
|
|
545
|
+
const token = '500-no-headers-no-cooldown-token';
|
|
546
|
+
writeRateLimit(token, { 'content-type': 'application/json' }, 500);
|
|
547
|
+
expect(fs.existsSync(cachePathForToken(token))).toBe(false);
|
|
548
|
+
expect(readRateLimit(token)).toBeNull();
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
449
552
|
describe('parseModelRateLimitsFromBody', () => {
|
|
450
553
|
it('should extract a rejected seven_day_sonnet limit from a rate_limit event body', () => {
|
|
451
554
|
const body =
|
|
@@ -20,12 +20,17 @@ export interface RateLimitSnapshot {
|
|
|
20
20
|
sevenDayRejected: boolean;
|
|
21
21
|
modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
|
|
22
22
|
lastUpdatedEpoch: number;
|
|
23
|
+
blockedUntilEpoch: number;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export const PROXY_PORT = 8787;
|
|
26
27
|
|
|
27
28
|
const HASH_ALGORITHM = 'sha256';
|
|
28
29
|
|
|
30
|
+
export const HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS = 90;
|
|
31
|
+
|
|
32
|
+
export const HEADERLESS_429_MAX_COOLDOWN_SECONDS = 600;
|
|
33
|
+
|
|
29
34
|
export const cacheDir = (): string => {
|
|
30
35
|
const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
|
|
31
36
|
return path.join(base, 'tdpm', 'ratelimit');
|
|
@@ -68,9 +73,21 @@ const readModelWeeklyLimits = (
|
|
|
68
73
|
return result;
|
|
69
74
|
};
|
|
70
75
|
|
|
76
|
+
const cooldownEndFromRetryAfter = (
|
|
77
|
+
retryAfterSeconds: number | null,
|
|
78
|
+
nowEpochSeconds: number,
|
|
79
|
+
): number => {
|
|
80
|
+
const cooldownSeconds =
|
|
81
|
+
retryAfterSeconds !== null && retryAfterSeconds > 0
|
|
82
|
+
? Math.min(retryAfterSeconds, HEADERLESS_429_MAX_COOLDOWN_SECONDS)
|
|
83
|
+
: HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS;
|
|
84
|
+
return nowEpochSeconds + cooldownSeconds;
|
|
85
|
+
};
|
|
86
|
+
|
|
71
87
|
export const writeRateLimit = (
|
|
72
88
|
token: string,
|
|
73
89
|
headers: Record<string, string | string[] | undefined>,
|
|
90
|
+
statusCode: number | null = null,
|
|
74
91
|
): void => {
|
|
75
92
|
const pick = (key: string): string | undefined => {
|
|
76
93
|
const value = headers[key];
|
|
@@ -86,14 +103,35 @@ export const writeRateLimit = (
|
|
|
86
103
|
}
|
|
87
104
|
}
|
|
88
105
|
}
|
|
106
|
+
const dir = cacheDir();
|
|
107
|
+
const filePath = path.join(dir, `${hashToken(token)}.json`);
|
|
89
108
|
if (Object.keys(rateLimitHeaders).length === 0) {
|
|
109
|
+
if (statusCode !== 429) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const existing = readPayload(filePath);
|
|
113
|
+
const retryAfterRaw = pick('retry-after');
|
|
114
|
+
const retryAfterSeconds =
|
|
115
|
+
retryAfterRaw !== undefined && Number.isFinite(Number(retryAfterRaw))
|
|
116
|
+
? Number(retryAfterRaw)
|
|
117
|
+
: null;
|
|
118
|
+
const blockedUntilEpoch = cooldownEndFromRetryAfter(
|
|
119
|
+
retryAfterSeconds,
|
|
120
|
+
Date.now() / 1000,
|
|
121
|
+
);
|
|
122
|
+
if (!fs.existsSync(dir)) {
|
|
123
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
const payload = {
|
|
126
|
+
...existing,
|
|
127
|
+
blockedUntilEpoch,
|
|
128
|
+
};
|
|
129
|
+
fs.writeFileSync(filePath, JSON.stringify(payload));
|
|
90
130
|
return;
|
|
91
131
|
}
|
|
92
|
-
const dir = cacheDir();
|
|
93
132
|
if (!fs.existsSync(dir)) {
|
|
94
133
|
fs.mkdirSync(dir, { recursive: true });
|
|
95
134
|
}
|
|
96
|
-
const filePath = path.join(dir, `${hashToken(token)}.json`);
|
|
97
135
|
const existing = readPayload(filePath);
|
|
98
136
|
const payload = {
|
|
99
137
|
ts: Date.now() / 1000,
|
|
@@ -186,6 +224,9 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
|
|
|
186
224
|
const sevenDayRejected = sevenDayStatus === 'rejected';
|
|
187
225
|
const storedTs = parsed.ts;
|
|
188
226
|
const lastUpdatedEpoch = typeof storedTs === 'number' ? storedTs : 0;
|
|
227
|
+
const storedBlockedUntil = parsed.blockedUntilEpoch;
|
|
228
|
+
const blockedUntilEpoch =
|
|
229
|
+
typeof storedBlockedUntil === 'number' ? storedBlockedUntil : 0;
|
|
189
230
|
return {
|
|
190
231
|
fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
|
|
191
232
|
fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
|
|
@@ -201,6 +242,7 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
|
|
|
201
242
|
sevenDayRejected,
|
|
202
243
|
modelWeeklyLimits: readModelWeeklyLimits(parsed),
|
|
203
244
|
lastUpdatedEpoch,
|
|
245
|
+
blockedUntilEpoch,
|
|
204
246
|
};
|
|
205
247
|
} catch {
|
|
206
248
|
return null;
|
|
@@ -291,6 +291,23 @@ describe('startProxy', () => {
|
|
|
291
291
|
expect(writeModelRateLimitSpy).toHaveBeenCalledTimes(1);
|
|
292
292
|
});
|
|
293
293
|
|
|
294
|
+
it('should forward the upstream status code to writeRateLimit on a 429 response', async () => {
|
|
295
|
+
upstreamHandler = (_request, response) => {
|
|
296
|
+
response.writeHead(429, { 'content-type': 'application/json' });
|
|
297
|
+
response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const response = await requestThroughProxy('POST', '/v1/messages', '{}');
|
|
301
|
+
|
|
302
|
+
expect(response.statusCode).toBe(429);
|
|
303
|
+
expect(writeRateLimitSpy).toHaveBeenCalledTimes(1);
|
|
304
|
+
expect(writeRateLimitSpy).toHaveBeenCalledWith(
|
|
305
|
+
TOKEN,
|
|
306
|
+
expect.objectContaining({ 'content-type': 'application/json' }),
|
|
307
|
+
429,
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
294
311
|
it('should forward non-SSE responses without crashing', async () => {
|
|
295
312
|
upstreamHandler = (_request, response) => {
|
|
296
313
|
response.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -50,7 +50,11 @@ const startProxy = (
|
|
|
50
50
|
(upstreamResponse) => {
|
|
51
51
|
if (token !== null) {
|
|
52
52
|
try {
|
|
53
|
-
writeRateLimit(
|
|
53
|
+
writeRateLimit(
|
|
54
|
+
token,
|
|
55
|
+
upstreamResponse.headers,
|
|
56
|
+
upstreamResponse.statusCode ?? null,
|
|
57
|
+
);
|
|
54
58
|
} catch (error) {
|
|
55
59
|
console.error('Failed to write rate limit cache:', error);
|
|
56
60
|
}
|
|
@@ -109,6 +109,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
109
109
|
sevenDayUtilization: 0,
|
|
110
110
|
blocked: false,
|
|
111
111
|
rejected: false,
|
|
112
|
+
blockedUntilEpoch: 0,
|
|
112
113
|
modelWeeklyLimits: {
|
|
113
114
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
114
115
|
},
|
|
@@ -120,6 +121,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
120
121
|
sevenDayUtilization: 0,
|
|
121
122
|
blocked: false,
|
|
122
123
|
rejected: false,
|
|
124
|
+
blockedUntilEpoch: 0,
|
|
123
125
|
modelWeeklyLimits: {},
|
|
124
126
|
},
|
|
125
127
|
]);
|
|
@@ -153,6 +155,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
153
155
|
sevenDayUtilization: 0,
|
|
154
156
|
blocked: true,
|
|
155
157
|
rejected: false,
|
|
158
|
+
blockedUntilEpoch: 0,
|
|
156
159
|
modelWeeklyLimits: {
|
|
157
160
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
158
161
|
},
|
|
@@ -188,6 +191,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
188
191
|
sevenDayUtilization: 0,
|
|
189
192
|
blocked: false,
|
|
190
193
|
rejected: true,
|
|
194
|
+
blockedUntilEpoch: 0,
|
|
191
195
|
modelWeeklyLimits: {
|
|
192
196
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
193
197
|
},
|
|
@@ -223,6 +227,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
223
227
|
sevenDayUtilization: 30,
|
|
224
228
|
blocked: false,
|
|
225
229
|
rejected: false,
|
|
230
|
+
blockedUntilEpoch: 0,
|
|
226
231
|
modelWeeklyLimits: {
|
|
227
232
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
228
233
|
},
|
|
@@ -258,6 +263,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
258
263
|
sevenDayUtilization: 0,
|
|
259
264
|
blocked: false,
|
|
260
265
|
rejected: false,
|
|
266
|
+
blockedUntilEpoch: 0,
|
|
261
267
|
modelWeeklyLimits: {
|
|
262
268
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
263
269
|
},
|
|
@@ -293,6 +299,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
293
299
|
sevenDayUtilization: 0,
|
|
294
300
|
blocked: false,
|
|
295
301
|
rejected: false,
|
|
302
|
+
blockedUntilEpoch: 0,
|
|
296
303
|
modelWeeklyLimits: {
|
|
297
304
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
298
305
|
},
|
|
@@ -328,6 +335,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
328
335
|
sevenDayUtilization: 0,
|
|
329
336
|
blocked: false,
|
|
330
337
|
rejected: false,
|
|
338
|
+
blockedUntilEpoch: 0,
|
|
331
339
|
modelWeeklyLimits: {},
|
|
332
340
|
},
|
|
333
341
|
]);
|
|
@@ -361,6 +369,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
361
369
|
sevenDayUtilization: 0,
|
|
362
370
|
blocked: false,
|
|
363
371
|
rejected: true,
|
|
372
|
+
blockedUntilEpoch: 0,
|
|
364
373
|
modelWeeklyLimits: {
|
|
365
374
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
366
375
|
},
|
|
@@ -396,6 +405,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
396
405
|
sevenDayUtilization: 100,
|
|
397
406
|
blocked: false,
|
|
398
407
|
rejected: true,
|
|
408
|
+
blockedUntilEpoch: 0,
|
|
399
409
|
modelWeeklyLimits: {
|
|
400
410
|
seven_day: { rejected: true, resetsAt: futureReset },
|
|
401
411
|
},
|
|
@@ -431,6 +441,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
431
441
|
sevenDayUtilization: 0,
|
|
432
442
|
blocked: false,
|
|
433
443
|
rejected: false,
|
|
444
|
+
blockedUntilEpoch: 0,
|
|
434
445
|
modelWeeklyLimits: {
|
|
435
446
|
seven_day: { rejected: false, resetsAt: futureReset },
|
|
436
447
|
},
|
|
@@ -455,6 +466,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
455
466
|
sevenDayUtilization: 0,
|
|
456
467
|
blocked: false,
|
|
457
468
|
rejected: false,
|
|
469
|
+
blockedUntilEpoch: 0,
|
|
458
470
|
modelWeeklyLimits: {},
|
|
459
471
|
},
|
|
460
472
|
]);
|
|
@@ -490,6 +502,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
490
502
|
sevenDayUtilization: 10,
|
|
491
503
|
blocked: false,
|
|
492
504
|
rejected: false,
|
|
505
|
+
blockedUntilEpoch: 0,
|
|
493
506
|
modelWeeklyLimits: {
|
|
494
507
|
seven_day_sonnet: { rejected: true, resetsAt: futureReset },
|
|
495
508
|
},
|
|
@@ -527,6 +540,7 @@ describe('ProxyClaudeTokenUsageRepository', () => {
|
|
|
527
540
|
sevenDayUtilization: 10,
|
|
528
541
|
blocked: false,
|
|
529
542
|
rejected: false,
|
|
543
|
+
blockedUntilEpoch: 0,
|
|
530
544
|
modelWeeklyLimits: {
|
|
531
545
|
seven_day_sonnet: { rejected: false, resetsAt: pastReset },
|
|
532
546
|
},
|
|
@@ -36,6 +36,7 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
|
|
|
36
36
|
blocked: false,
|
|
37
37
|
rejected: false,
|
|
38
38
|
modelWeeklyLimits: {},
|
|
39
|
+
blockedUntilEpoch: 0,
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
const fiveHourExpired = nowEpochSeconds > snapshot.fiveHourReset;
|
|
@@ -83,6 +84,7 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
|
|
|
83
84
|
resetsAt: snapshot.sevenDayReset,
|
|
84
85
|
};
|
|
85
86
|
}
|
|
87
|
+
const cooldownActive = snapshot.blockedUntilEpoch > nowEpochSeconds;
|
|
86
88
|
return {
|
|
87
89
|
name,
|
|
88
90
|
token,
|
|
@@ -91,6 +93,7 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
|
|
|
91
93
|
blocked: snapshot.blocked,
|
|
92
94
|
rejected,
|
|
93
95
|
modelWeeklyLimits,
|
|
96
|
+
blockedUntilEpoch: cooldownActive ? snapshot.blockedUntilEpoch : 0,
|
|
94
97
|
};
|
|
95
98
|
});
|
|
96
99
|
};
|
|
@@ -116,6 +116,74 @@ describe('ChangeTargetPullRequestApprover', () => {
|
|
|
116
116
|
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
it('should normalize leading slashes in change-target label paths', async () => {
|
|
120
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
121
|
+
'src/domain/entities/Foo.ts',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
await approver.approveIfConfined(['change-target:/src/domain'], prUrl);
|
|
125
|
+
|
|
126
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should normalize both leading and trailing slashes in change-target label paths', async () => {
|
|
130
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
131
|
+
'src/domain/entities/Foo.ts',
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
await approver.approveIfConfined(['change-target:/src/domain/'], prUrl);
|
|
135
|
+
|
|
136
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should expand a path alias when pathAliases map is provided', async () => {
|
|
140
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
141
|
+
'src/domain/usecases/adapter-interfaces/IssueRepository.ts',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
await approver.approveIfConfined(['change-target:adapters'], prUrl, {
|
|
145
|
+
adapters: 'src/domain/usecases/adapter-interfaces',
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should not approve when file is outside the expanded alias path', async () => {
|
|
152
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
153
|
+
'src/domain/usecases/SomeOtherUseCase.ts',
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
await approver.approveIfConfined(['change-target:adapters'], prUrl, {
|
|
157
|
+
adapters: 'src/domain/usecases/adapter-interfaces',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should treat label value as literal path when it does not match any alias key', async () => {
|
|
164
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
165
|
+
'src/domain/entities/Foo.ts',
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
await approver.approveIfConfined(['change-target:src/domain'], prUrl, {
|
|
169
|
+
adapters: 'src/domain/usecases/adapter-interfaces',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should normalize leading slash in alias expanded value', async () => {
|
|
176
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
177
|
+
'src/domain/entities/Foo.ts',
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
await approver.approveIfConfined(['change-target:domain'], prUrl, {
|
|
181
|
+
domain: '/src/domain',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
185
|
+
});
|
|
186
|
+
|
|
119
187
|
it('should ignore empty change-target label values', async () => {
|
|
120
188
|
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
121
189
|
'src/domain/Foo.ts',
|
|
@@ -129,6 +197,16 @@ describe('ChangeTargetPullRequestApprover', () => {
|
|
|
129
197
|
expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
|
|
130
198
|
});
|
|
131
199
|
|
|
200
|
+
it('should normalize leading slashes in change-target-must label paths', async () => {
|
|
201
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
202
|
+
'src/domain/entities/Foo.ts',
|
|
203
|
+
]);
|
|
204
|
+
|
|
205
|
+
await approver.approveIfConfined(['change-target-must:/src/domain'], prUrl);
|
|
206
|
+
|
|
207
|
+
expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(prUrl);
|
|
208
|
+
});
|
|
209
|
+
|
|
132
210
|
it('should treat change-target-must: path as an allowed confinement path and approve when all files are confined to it', async () => {
|
|
133
211
|
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
134
212
|
'src/domain/entities/Foo.ts',
|
|
@@ -11,11 +11,15 @@ export class ChangeTargetPullRequestApprover {
|
|
|
11
11
|
approveIfConfined = async (
|
|
12
12
|
issueLabels: string[],
|
|
13
13
|
approvedPrUrl: string | null,
|
|
14
|
+
pathAliases?: Record<string, string> | null,
|
|
14
15
|
): Promise<void> => {
|
|
15
16
|
if (approvedPrUrl === null) {
|
|
16
17
|
return;
|
|
17
18
|
}
|
|
18
|
-
const changeTargetPaths = this.extractChangeTargetPaths(
|
|
19
|
+
const changeTargetPaths = this.extractChangeTargetPaths(
|
|
20
|
+
issueLabels,
|
|
21
|
+
pathAliases,
|
|
22
|
+
);
|
|
19
23
|
if (changeTargetPaths.length === 0) {
|
|
20
24
|
return;
|
|
21
25
|
}
|
|
@@ -33,7 +37,10 @@ export class ChangeTargetPullRequestApprover {
|
|
|
33
37
|
await this.issueRepository.approvePullRequest(approvedPrUrl);
|
|
34
38
|
};
|
|
35
39
|
|
|
36
|
-
private extractChangeTargetPaths = (
|
|
40
|
+
private extractChangeTargetPaths = (
|
|
41
|
+
labels: string[],
|
|
42
|
+
pathAliases?: Record<string, string> | null,
|
|
43
|
+
): string[] => {
|
|
37
44
|
const prefixes = ['change-target:', 'change-target-must:'];
|
|
38
45
|
const paths: string[] = [];
|
|
39
46
|
for (const label of labels) {
|
|
@@ -41,9 +48,12 @@ export class ChangeTargetPullRequestApprover {
|
|
|
41
48
|
if (!matchedPrefix) continue;
|
|
42
49
|
const raw = label.slice(matchedPrefix.length).trim();
|
|
43
50
|
if (raw.length === 0) continue;
|
|
44
|
-
const normalized = raw.replace(/\/+$/, '');
|
|
51
|
+
const normalized = raw.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
45
52
|
if (normalized.length === 0) continue;
|
|
46
|
-
|
|
53
|
+
const aliasExpanded = pathAliases?.[normalized] ?? normalized;
|
|
54
|
+
const finalPath = aliasExpanded.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
55
|
+
if (finalPath.length === 0) continue;
|
|
56
|
+
paths.push(finalPath);
|
|
47
57
|
}
|
|
48
58
|
return paths;
|
|
49
59
|
};
|
|
@@ -85,6 +85,7 @@ export class HandleScheduledEventUseCase {
|
|
|
85
85
|
disabled: boolean;
|
|
86
86
|
allowIssueCacheMinutes: number;
|
|
87
87
|
labelsAsLlmAgentName?: string[] | null;
|
|
88
|
+
changeTargetPathAliases?: Record<string, string> | null;
|
|
88
89
|
startPreparation?: {
|
|
89
90
|
defaultAgentName: string;
|
|
90
91
|
defaultLlmModelName?: string | null;
|
|
@@ -296,6 +297,7 @@ ${JSON.stringify(e)}
|
|
|
296
297
|
projectUrl: input.projectUrl,
|
|
297
298
|
allowIssueCacheMinutes: input.allowIssueCacheMinutes,
|
|
298
299
|
labelsAsLlmAgentName,
|
|
300
|
+
changeTargetPathAliases: input.changeTargetPathAliases,
|
|
299
301
|
});
|
|
300
302
|
if (this.dailySecurityScanUseCase !== null && input.dailySecurityScan) {
|
|
301
303
|
await this.dailySecurityScanUseCase.run({
|
|
@@ -340,6 +340,24 @@ describe('IssueRejectionEvaluator', () => {
|
|
|
340
340
|
expect(result.rejections).toHaveLength(0);
|
|
341
341
|
expect(result.approvedPrUrl).toBe(prUrl);
|
|
342
342
|
});
|
|
343
|
+
|
|
344
|
+
it('should normalize leading slash in change-target-must label path', async () => {
|
|
345
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
|
|
346
|
+
createReadyPr(prUrl),
|
|
347
|
+
]);
|
|
348
|
+
mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
|
|
349
|
+
'src/domain/entities/Foo.ts',
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const result = await evaluator.evaluate({
|
|
353
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
354
|
+
labels: ['change-target-must:/src/domain'],
|
|
355
|
+
isPr: false,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(result.rejections).toHaveLength(0);
|
|
359
|
+
expect(result.approvedPrUrl).toBe(prUrl);
|
|
360
|
+
});
|
|
343
361
|
});
|
|
344
362
|
});
|
|
345
363
|
});
|
|
@@ -167,7 +167,7 @@ export class IssueRejectionEvaluator {
|
|
|
167
167
|
if (!label.startsWith(prefix)) continue;
|
|
168
168
|
const raw = label.slice(prefix.length).trim();
|
|
169
169
|
if (raw.length === 0) continue;
|
|
170
|
-
const normalized = raw.replace(/\/+$/, '');
|
|
170
|
+
const normalized = raw.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
171
171
|
if (normalized.length === 0) continue;
|
|
172
172
|
paths.push(normalized);
|
|
173
173
|
}
|