github-issue-tower-defence-management 1.88.0 → 1.89.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 +14 -0
- package/README.md +20 -1
- package/bin/adapter/entry-points/cli/index.js +56 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleDataDelivery.js +155 -0
- package/bin/adapter/entry-points/console/consoleDataDelivery.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleDoneStore.js +100 -0
- package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js +178 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js +119 -0
- package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js +147 -3
- package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +97 -0
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -3
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +3 -0
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +87 -0
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +94 -0
- package/src/adapter/entry-points/cli/index.ts +99 -0
- package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +184 -0
- package/src/adapter/entry-points/console/consoleDataDelivery.ts +169 -0
- package/src/adapter/entry-points/console/consoleDoneStore.test.ts +98 -0
- package/src/adapter/entry-points/console/consoleDoneStore.ts +91 -0
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +444 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +280 -0
- package/src/adapter/entry-points/console/consoleReadApi.test.ts +297 -0
- package/src/adapter/entry-points/console/consoleReadApi.ts +192 -0
- package/src/adapter/entry-points/console/consoleServer.test.ts +269 -0
- package/src/adapter/entry-points/console/consoleServer.ts +228 -4
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +204 -0
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +132 -0
- package/src/adapter/proxy/RateLimitCache.ts +9 -4
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +34 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +179 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +158 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleDataDelivery.d.ts +23 -0
- package/types/adapter/entry-points/console/consoleDataDelivery.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleDoneStore.d.ts +10 -0
- package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +18 -0
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts +44 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -0
- package/types/adapter/entry-points/console/consoleServer.d.ts +8 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +20 -0
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -2
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +35 -0
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -0
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
import * as http from 'http';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
+
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
5
|
+
import { Project } from '../../../domain/entities/Project';
|
|
6
|
+
import {
|
|
7
|
+
buildConsoleDataResponse,
|
|
8
|
+
parseConsoleDataRoute,
|
|
9
|
+
} from './consoleDataDelivery';
|
|
10
|
+
import {
|
|
11
|
+
IssueTitleStateCache,
|
|
12
|
+
handleComments,
|
|
13
|
+
handleIssueTitle,
|
|
14
|
+
handleItemBody,
|
|
15
|
+
handlePrCommits,
|
|
16
|
+
handlePrFiles,
|
|
17
|
+
handleRelatedPrs,
|
|
18
|
+
} from './consoleReadApi';
|
|
19
|
+
import {
|
|
20
|
+
ConsoleOperationContext,
|
|
21
|
+
handleIntmux,
|
|
22
|
+
handleReview,
|
|
23
|
+
handleTriage,
|
|
24
|
+
} from './consoleOperationApi';
|
|
4
25
|
|
|
5
26
|
export const DEFAULT_CONSOLE_PORT = 9981;
|
|
6
27
|
|
|
@@ -108,6 +129,10 @@ export type ConsoleServerOptions = {
|
|
|
108
129
|
accessToken: string;
|
|
109
130
|
uiDistDir: string;
|
|
110
131
|
consoleDataOutputDir: string | null;
|
|
132
|
+
pjcode?: string | null;
|
|
133
|
+
issueRepository?: IssueRepository | null;
|
|
134
|
+
project?: Project | null;
|
|
135
|
+
issueTitleStateCache?: IssueTitleStateCache | null;
|
|
111
136
|
};
|
|
112
137
|
|
|
113
138
|
const sendNotFound = (response: http.ServerResponse): void => {
|
|
@@ -134,11 +159,189 @@ const serveBootstrapIndex = (response: http.ServerResponse): void => {
|
|
|
134
159
|
response.end(PLACEHOLDER_INDEX_HTML);
|
|
135
160
|
};
|
|
136
161
|
|
|
137
|
-
|
|
162
|
+
const sendJson = (
|
|
163
|
+
response: http.ServerResponse,
|
|
164
|
+
statusCode: number,
|
|
165
|
+
body: unknown,
|
|
166
|
+
): void => {
|
|
167
|
+
response.writeHead(statusCode, {
|
|
168
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
169
|
+
'Cache-Control': 'no-store',
|
|
170
|
+
});
|
|
171
|
+
response.end(JSON.stringify(body));
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const sendDataResponse = (
|
|
175
|
+
response: http.ServerResponse,
|
|
176
|
+
statusCode: number,
|
|
177
|
+
contentType: string,
|
|
178
|
+
body: string,
|
|
179
|
+
): void => {
|
|
180
|
+
response.writeHead(statusCode, {
|
|
181
|
+
'Content-Type': contentType,
|
|
182
|
+
'Cache-Control': 'no-store',
|
|
183
|
+
});
|
|
184
|
+
response.end(body);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const readRequestBody = (request: http.IncomingMessage): Promise<string> =>
|
|
188
|
+
new Promise((resolve, reject) => {
|
|
189
|
+
const chunks: Uint8Array[] = [];
|
|
190
|
+
request.on('data', (chunk: Uint8Array) => chunks.push(chunk));
|
|
191
|
+
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
192
|
+
request.on('error', reject);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
196
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
197
|
+
|
|
198
|
+
const parseRequestBody = (raw: string): Record<string, unknown> | null => {
|
|
199
|
+
if (raw.length === 0) {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
let parsed: unknown;
|
|
203
|
+
try {
|
|
204
|
+
parsed = JSON.parse(raw);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (!isRecord(parsed)) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return parsed;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const handleReadApi = async (
|
|
215
|
+
options: ConsoleServerOptions,
|
|
216
|
+
requestPath: string,
|
|
217
|
+
searchParams: URLSearchParams,
|
|
218
|
+
): Promise<{ statusCode: number; body: unknown } | null> => {
|
|
219
|
+
const issueRepository = options.issueRepository ?? null;
|
|
220
|
+
if (issueRepository === null) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const cache = options.issueTitleStateCache ?? null;
|
|
224
|
+
const url = searchParams.get('url');
|
|
225
|
+
switch (requestPath) {
|
|
226
|
+
case '/api/itembody':
|
|
227
|
+
return handleItemBody(issueRepository, url);
|
|
228
|
+
case '/api/comments':
|
|
229
|
+
return handleComments(issueRepository, url);
|
|
230
|
+
case '/api/prfiles':
|
|
231
|
+
return handlePrFiles(issueRepository, url);
|
|
232
|
+
case '/api/prcommits':
|
|
233
|
+
return handlePrCommits(issueRepository, url);
|
|
234
|
+
case '/api/relatedprs':
|
|
235
|
+
return handleRelatedPrs(issueRepository, url);
|
|
236
|
+
case '/api/issuetitle':
|
|
237
|
+
if (cache === null) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return handleIssueTitle(issueRepository, cache, url);
|
|
241
|
+
default:
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const handleOperationApi = async (
|
|
247
|
+
options: ConsoleServerOptions,
|
|
248
|
+
requestPath: string,
|
|
249
|
+
body: Record<string, unknown>,
|
|
250
|
+
): Promise<{ statusCode: number; body: unknown } | null> => {
|
|
251
|
+
const issueRepository = options.issueRepository ?? null;
|
|
252
|
+
const project = options.project ?? null;
|
|
253
|
+
if (issueRepository === null || project === null) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
const context: ConsoleOperationContext = {
|
|
257
|
+
issueRepository,
|
|
258
|
+
project,
|
|
259
|
+
consoleDataOutputDir: options.consoleDataOutputDir,
|
|
260
|
+
pjcode: options.pjcode ?? null,
|
|
261
|
+
};
|
|
262
|
+
switch (requestPath) {
|
|
263
|
+
case '/api/review':
|
|
264
|
+
return handleReview(context, body);
|
|
265
|
+
case '/api/triage':
|
|
266
|
+
return handleTriage(context, body);
|
|
267
|
+
case '/api/intmux':
|
|
268
|
+
return handleIntmux(context, body);
|
|
269
|
+
default:
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const handleTokenedRequest = async (
|
|
138
275
|
options: ConsoleServerOptions,
|
|
139
276
|
request: http.IncomingMessage,
|
|
140
277
|
response: http.ServerResponse,
|
|
141
|
-
|
|
278
|
+
requestPath: string,
|
|
279
|
+
searchParams: URLSearchParams,
|
|
280
|
+
): Promise<void> => {
|
|
281
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
282
|
+
|
|
283
|
+
if (requestPath.startsWith('/api/')) {
|
|
284
|
+
if (method === 'GET') {
|
|
285
|
+
const readResult = await handleReadApi(
|
|
286
|
+
options,
|
|
287
|
+
requestPath,
|
|
288
|
+
searchParams,
|
|
289
|
+
);
|
|
290
|
+
if (readResult === null) {
|
|
291
|
+
sendNotFound(response);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
sendJson(response, readResult.statusCode, readResult.body);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (method === 'POST') {
|
|
298
|
+
const raw = await readRequestBody(request);
|
|
299
|
+
const parsedBody = parseRequestBody(raw);
|
|
300
|
+
if (parsedBody === null) {
|
|
301
|
+
sendJson(response, 400, { error: 'invalid JSON body' });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const operationResult = await handleOperationApi(
|
|
305
|
+
options,
|
|
306
|
+
requestPath,
|
|
307
|
+
parsedBody,
|
|
308
|
+
);
|
|
309
|
+
if (operationResult === null) {
|
|
310
|
+
sendNotFound(response);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
sendJson(response, operationResult.statusCode, operationResult.body);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
sendNotFound(response);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (method === 'GET') {
|
|
321
|
+
const dataRoute = parseConsoleDataRoute(requestPath);
|
|
322
|
+
if (dataRoute !== null && options.consoleDataOutputDir !== null) {
|
|
323
|
+
const dataResponse = buildConsoleDataResponse(
|
|
324
|
+
options.consoleDataOutputDir,
|
|
325
|
+
dataRoute,
|
|
326
|
+
);
|
|
327
|
+
sendDataResponse(
|
|
328
|
+
response,
|
|
329
|
+
dataResponse.statusCode,
|
|
330
|
+
dataResponse.contentType,
|
|
331
|
+
dataResponse.body,
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
sendNotFound(response);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export const handleConsoleRequest = async (
|
|
341
|
+
options: ConsoleServerOptions,
|
|
342
|
+
request: http.IncomingMessage,
|
|
343
|
+
response: http.ServerResponse,
|
|
344
|
+
): Promise<void> => {
|
|
142
345
|
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
|
143
346
|
const requestPath = requestUrl.pathname;
|
|
144
347
|
|
|
@@ -156,7 +359,13 @@ export const handleConsoleRequest = (
|
|
|
156
359
|
sendUnauthorized(response);
|
|
157
360
|
return;
|
|
158
361
|
}
|
|
159
|
-
|
|
362
|
+
await handleTokenedRequest(
|
|
363
|
+
options,
|
|
364
|
+
request,
|
|
365
|
+
response,
|
|
366
|
+
requestPath,
|
|
367
|
+
requestUrl.searchParams,
|
|
368
|
+
);
|
|
160
369
|
return;
|
|
161
370
|
}
|
|
162
371
|
|
|
@@ -196,11 +405,26 @@ export const handleConsoleRequest = (
|
|
|
196
405
|
response.end(staticContent);
|
|
197
406
|
};
|
|
198
407
|
|
|
408
|
+
const sendInternalServerError = (response: http.ServerResponse): void => {
|
|
409
|
+
if (response.headersSent) {
|
|
410
|
+
response.end();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
response.writeHead(500, {
|
|
414
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
415
|
+
'Cache-Control': 'no-store',
|
|
416
|
+
});
|
|
417
|
+
response.end('Internal Server Error');
|
|
418
|
+
};
|
|
419
|
+
|
|
199
420
|
export const createConsoleServer = (
|
|
200
421
|
options: ConsoleServerOptions,
|
|
201
422
|
): http.Server =>
|
|
202
423
|
http.createServer((request, response) => {
|
|
203
|
-
handleConsoleRequest(options, request, response)
|
|
424
|
+
handleConsoleRequest(options, request, response).catch((error) => {
|
|
425
|
+
console.error('console request failed', error);
|
|
426
|
+
sendInternalServerError(response);
|
|
427
|
+
});
|
|
204
428
|
});
|
|
205
429
|
|
|
206
430
|
export type StartConsoleServerOptions = ConsoleServerOptions & {
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { hashToken } from '../../proxy/RateLimitCache';
|
|
5
|
+
import {
|
|
6
|
+
OauthTokenSelectHandler,
|
|
7
|
+
resolveCacheDirectory,
|
|
8
|
+
resolveTokenListJsonPath,
|
|
9
|
+
} from './OauthTokenSelectHandler';
|
|
10
|
+
|
|
11
|
+
const NOW = 2_000_000;
|
|
12
|
+
const HOUR = 3600;
|
|
13
|
+
const DAY = 86400;
|
|
14
|
+
|
|
15
|
+
type FakeHeaders = {
|
|
16
|
+
fiveHourUtilization: number;
|
|
17
|
+
fiveHourReset: number;
|
|
18
|
+
sevenDayUtilization: number;
|
|
19
|
+
sevenDayReset: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('OauthTokenSelectHandler', () => {
|
|
23
|
+
let tempDir: string;
|
|
24
|
+
let cacheDirectory: string;
|
|
25
|
+
let tokenListPath: string;
|
|
26
|
+
const originalTokenListEnv =
|
|
27
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
|
|
28
|
+
const originalCacheEnv = process.env.TDPM_RATELIMIT_CACHE_DIR;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'select-token-'));
|
|
32
|
+
cacheDirectory = path.join(tempDir, 'cache');
|
|
33
|
+
fs.mkdirSync(cacheDirectory, { recursive: true });
|
|
34
|
+
tokenListPath = path.join(tempDir, 'tokens.json');
|
|
35
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
|
|
36
|
+
delete process.env.TDPM_RATELIMIT_CACHE_DIR;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (originalTokenListEnv === undefined) {
|
|
41
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = originalTokenListEnv;
|
|
44
|
+
}
|
|
45
|
+
if (originalCacheEnv === undefined) {
|
|
46
|
+
delete process.env.TDPM_RATELIMIT_CACHE_DIR;
|
|
47
|
+
} else {
|
|
48
|
+
process.env.TDPM_RATELIMIT_CACHE_DIR = originalCacheEnv;
|
|
49
|
+
}
|
|
50
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const writeTokenList = (entries: { name: string; token: string }[]): void => {
|
|
54
|
+
fs.writeFileSync(tokenListPath, JSON.stringify(entries));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const writeCache = (token: string, headers: FakeHeaders): void => {
|
|
58
|
+
const payload = {
|
|
59
|
+
ts: NOW,
|
|
60
|
+
headers: {
|
|
61
|
+
'anthropic-ratelimit-unified-status': 'allowed',
|
|
62
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
63
|
+
'anthropic-ratelimit-unified-5h-reset': String(headers.fiveHourReset),
|
|
64
|
+
'anthropic-ratelimit-unified-5h-utilization': String(
|
|
65
|
+
headers.fiveHourUtilization,
|
|
66
|
+
),
|
|
67
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
68
|
+
'anthropic-ratelimit-unified-7d-reset': String(headers.sevenDayReset),
|
|
69
|
+
'anthropic-ratelimit-unified-7d-utilization': String(
|
|
70
|
+
headers.sevenDayUtilization,
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
modelWeeklyLimits: {},
|
|
74
|
+
};
|
|
75
|
+
fs.writeFileSync(
|
|
76
|
+
path.join(cacheDirectory, `${hashToken(token)}.json`),
|
|
77
|
+
JSON.stringify(payload),
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
it('selects the eligible token with the soonest 7d reset', () => {
|
|
82
|
+
writeTokenList([
|
|
83
|
+
{ name: 'far', token: 'fake-far' },
|
|
84
|
+
{ name: 'soon', token: 'fake-soon' },
|
|
85
|
+
]);
|
|
86
|
+
writeCache('fake-far', {
|
|
87
|
+
fiveHourUtilization: 0.1,
|
|
88
|
+
fiveHourReset: NOW + HOUR,
|
|
89
|
+
sevenDayUtilization: 0.1,
|
|
90
|
+
sevenDayReset: NOW + 6 * DAY,
|
|
91
|
+
});
|
|
92
|
+
writeCache('fake-soon', {
|
|
93
|
+
fiveHourUtilization: 0.1,
|
|
94
|
+
fiveHourReset: NOW + HOUR,
|
|
95
|
+
sevenDayUtilization: 0.1,
|
|
96
|
+
sevenDayReset: NOW + 2 * DAY,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const handler = new OauthTokenSelectHandler();
|
|
100
|
+
const output = handler.handle({
|
|
101
|
+
tokenListJsonPath: tokenListPath,
|
|
102
|
+
cacheDirectory,
|
|
103
|
+
nowEpochSeconds: NOW,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(output.selectedName).toBe('soon');
|
|
107
|
+
expect(output.selectedToken).toBe('fake-soon');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats a token with no cache file as fully free', () => {
|
|
111
|
+
writeTokenList([{ name: 'fresh', token: 'fake-fresh' }]);
|
|
112
|
+
|
|
113
|
+
const handler = new OauthTokenSelectHandler();
|
|
114
|
+
const output = handler.handle({
|
|
115
|
+
tokenListJsonPath: tokenListPath,
|
|
116
|
+
cacheDirectory,
|
|
117
|
+
nowEpochSeconds: NOW,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(output.selectedName).toBe('fresh');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns null and a diagnostic when no token passes the filter', () => {
|
|
124
|
+
writeTokenList([{ name: 'busy', token: 'fake-busy' }]);
|
|
125
|
+
writeCache('fake-busy', {
|
|
126
|
+
fiveHourUtilization: 0.9,
|
|
127
|
+
fiveHourReset: NOW + HOUR,
|
|
128
|
+
sevenDayUtilization: 0.1,
|
|
129
|
+
sevenDayReset: NOW + DAY,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const handler = new OauthTokenSelectHandler();
|
|
133
|
+
const output = handler.handle({
|
|
134
|
+
tokenListJsonPath: tokenListPath,
|
|
135
|
+
cacheDirectory,
|
|
136
|
+
nowEpochSeconds: NOW,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(output.selectedToken).toBeNull();
|
|
140
|
+
expect(output.diagnostics.join('\n')).toContain(
|
|
141
|
+
'No eligible token passed the rate-limit filter.',
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns a diagnostic when no token list path is resolvable', () => {
|
|
146
|
+
const handler = new OauthTokenSelectHandler();
|
|
147
|
+
const output = handler.handle({
|
|
148
|
+
tokenListJsonPath: null,
|
|
149
|
+
cacheDirectory,
|
|
150
|
+
nowEpochSeconds: NOW,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(output.selectedToken).toBeNull();
|
|
154
|
+
expect(output.diagnostics.join('\n')).toContain('No token list path');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('returns a diagnostic when the token list file has no usable entries', () => {
|
|
158
|
+
fs.writeFileSync(tokenListPath, JSON.stringify([]));
|
|
159
|
+
|
|
160
|
+
const handler = new OauthTokenSelectHandler();
|
|
161
|
+
const output = handler.handle({
|
|
162
|
+
tokenListJsonPath: tokenListPath,
|
|
163
|
+
cacheDirectory,
|
|
164
|
+
nowEpochSeconds: NOW,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(output.selectedToken).toBeNull();
|
|
168
|
+
expect(output.diagnostics.join('\n')).toContain('No usable token entries');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('resolveTokenListJsonPath', () => {
|
|
172
|
+
it('prefers the explicit path over the environment variable', () => {
|
|
173
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = '/from/env.json';
|
|
174
|
+
expect(resolveTokenListJsonPath('/explicit.json')).toBe('/explicit.json');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('falls back to the environment variable when no explicit path is given', () => {
|
|
178
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = '/from/env.json';
|
|
179
|
+
expect(resolveTokenListJsonPath(null)).toBe('/from/env.json');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns null when neither source provides a path', () => {
|
|
183
|
+
expect(resolveTokenListJsonPath(null)).toBeNull();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('resolveCacheDirectory', () => {
|
|
188
|
+
it('prefers the explicit directory over the environment variable', () => {
|
|
189
|
+
process.env.TDPM_RATELIMIT_CACHE_DIR = '/from/env';
|
|
190
|
+
expect(resolveCacheDirectory('/explicit')).toBe('/explicit');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('falls back to the environment variable when no explicit directory is given', () => {
|
|
194
|
+
process.env.TDPM_RATELIMIT_CACHE_DIR = '/from/env';
|
|
195
|
+
expect(resolveCacheDirectory(null)).toBe('/from/env');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('falls back to the default tdpm cache directory', () => {
|
|
199
|
+
expect(resolveCacheDirectory(null)).toContain(
|
|
200
|
+
path.join('tdpm', 'ratelimit'),
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OauthTokenCandidate,
|
|
3
|
+
OauthTokenSelectResult,
|
|
4
|
+
OauthTokenSelectUseCase,
|
|
5
|
+
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
6
|
+
import { cacheDir, readRateLimit } from '../../proxy/RateLimitCache';
|
|
7
|
+
import { loadTokenEntries } from '../../proxy/TokenListLoader';
|
|
8
|
+
|
|
9
|
+
export type OauthTokenSelectHandlerInput = {
|
|
10
|
+
tokenListJsonPath: string | null;
|
|
11
|
+
cacheDirectory: string | null;
|
|
12
|
+
nowEpochSeconds: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type OauthTokenSelectHandlerOutput = {
|
|
16
|
+
selectedToken: string | null;
|
|
17
|
+
selectedName: string | null;
|
|
18
|
+
diagnostics: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TOKEN_LIST_PATH_ENV = 'CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH';
|
|
22
|
+
const DEFAULT_CACHE_DIRECTORY_ENV = 'TDPM_RATELIMIT_CACHE_DIR';
|
|
23
|
+
|
|
24
|
+
export const resolveTokenListJsonPath = (
|
|
25
|
+
explicitPath: string | null,
|
|
26
|
+
): string | null => {
|
|
27
|
+
if (explicitPath !== null && explicitPath.length > 0) {
|
|
28
|
+
return explicitPath;
|
|
29
|
+
}
|
|
30
|
+
const fromEnv = process.env[DEFAULT_TOKEN_LIST_PATH_ENV];
|
|
31
|
+
if (fromEnv !== undefined && fromEnv.length > 0) {
|
|
32
|
+
return fromEnv;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const resolveCacheDirectory = (
|
|
38
|
+
explicitDirectory: string | null,
|
|
39
|
+
): string => {
|
|
40
|
+
if (explicitDirectory !== null && explicitDirectory.length > 0) {
|
|
41
|
+
return explicitDirectory;
|
|
42
|
+
}
|
|
43
|
+
const fromEnv = process.env[DEFAULT_CACHE_DIRECTORY_ENV];
|
|
44
|
+
if (fromEnv !== undefined && fromEnv.length > 0) {
|
|
45
|
+
return fromEnv;
|
|
46
|
+
}
|
|
47
|
+
return cacheDir();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export class OauthTokenSelectHandler {
|
|
51
|
+
constructor(
|
|
52
|
+
private readonly useCase: OauthTokenSelectUseCase = new OauthTokenSelectUseCase(),
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
handle = (
|
|
56
|
+
input: OauthTokenSelectHandlerInput,
|
|
57
|
+
): OauthTokenSelectHandlerOutput => {
|
|
58
|
+
const tokenListJsonPath = resolveTokenListJsonPath(input.tokenListJsonPath);
|
|
59
|
+
if (tokenListJsonPath === null) {
|
|
60
|
+
return {
|
|
61
|
+
selectedToken: null,
|
|
62
|
+
selectedName: null,
|
|
63
|
+
diagnostics: [
|
|
64
|
+
`No token list path provided. Pass --tokenListJsonPath or set ${DEFAULT_TOKEN_LIST_PATH_ENV}.`,
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const entries = loadTokenEntries(tokenListJsonPath);
|
|
70
|
+
if (entries === null) {
|
|
71
|
+
return {
|
|
72
|
+
selectedToken: null,
|
|
73
|
+
selectedName: null,
|
|
74
|
+
diagnostics: [
|
|
75
|
+
`No usable token entries loaded from ${tokenListJsonPath}.`,
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cacheDirectory = resolveCacheDirectory(input.cacheDirectory);
|
|
81
|
+
|
|
82
|
+
const candidates: OauthTokenCandidate[] = entries.map(({ name, token }) => {
|
|
83
|
+
const snapshot = readRateLimit(token, cacheDirectory);
|
|
84
|
+
return {
|
|
85
|
+
name,
|
|
86
|
+
token,
|
|
87
|
+
snapshot:
|
|
88
|
+
snapshot === null
|
|
89
|
+
? null
|
|
90
|
+
: {
|
|
91
|
+
fiveHourUtilization: snapshot.fiveHourUtilization,
|
|
92
|
+
fiveHourReset: snapshot.fiveHourReset,
|
|
93
|
+
sevenDayUtilization: snapshot.sevenDayUtilization,
|
|
94
|
+
sevenDayReset: snapshot.sevenDayReset,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = this.useCase.run(candidates, input.nowEpochSeconds);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
selectedToken: result.selected?.token ?? null,
|
|
103
|
+
selectedName: result.selected?.name ?? null,
|
|
104
|
+
diagnostics: this.formatDiagnostics(result, input.nowEpochSeconds),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
private formatDiagnostics = (
|
|
109
|
+
result: OauthTokenSelectResult,
|
|
110
|
+
nowEpochSeconds: number,
|
|
111
|
+
): string[] => {
|
|
112
|
+
const lines = result.metrics.map((metric) => {
|
|
113
|
+
const secondsUntilSevenDayEnd = Math.round(
|
|
114
|
+
metric.sevenDayEndEpoch - nowEpochSeconds,
|
|
115
|
+
);
|
|
116
|
+
const status = metric.eligible
|
|
117
|
+
? 'eligible'
|
|
118
|
+
: `excluded (${metric.exclusionReason})`;
|
|
119
|
+
return `${metric.name}: 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (result.selected === null) {
|
|
123
|
+
lines.push('No eligible token passed the rate-limit filter.');
|
|
124
|
+
} else {
|
|
125
|
+
lines.push(
|
|
126
|
+
`Selected ${result.selected.name} (soonest 7d reset among eligible tokens).`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return lines;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -39,8 +39,10 @@ export const cacheDir = (): string => {
|
|
|
39
39
|
export const hashToken = (token: string): string =>
|
|
40
40
|
crypto.createHash(HASH_ALGORITHM).update(token).digest('hex');
|
|
41
41
|
|
|
42
|
-
export const cachePathForToken = (
|
|
43
|
-
|
|
42
|
+
export const cachePathForToken = (
|
|
43
|
+
token: string,
|
|
44
|
+
baseDir: string = cacheDir(),
|
|
45
|
+
): string => path.join(baseDir, `${hashToken(token)}.json`);
|
|
44
46
|
|
|
45
47
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
46
48
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -222,8 +224,11 @@ export const parseModelRateLimitsFromHeaders = (
|
|
|
222
224
|
return result;
|
|
223
225
|
};
|
|
224
226
|
|
|
225
|
-
export const readRateLimit = (
|
|
226
|
-
|
|
227
|
+
export const readRateLimit = (
|
|
228
|
+
token: string,
|
|
229
|
+
baseDir: string = cacheDir(),
|
|
230
|
+
): RateLimitSnapshot | null => {
|
|
231
|
+
const filePath = cachePathForToken(token, baseDir);
|
|
227
232
|
if (!fs.existsSync(filePath)) return null;
|
|
228
233
|
try {
|
|
229
234
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
@@ -1876,6 +1876,40 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
1876
1876
|
);
|
|
1877
1877
|
});
|
|
1878
1878
|
|
|
1879
|
+
it('should not call setDependedIssueUrl when issue is a PR and the resolved PR URL matches the issue URL (self-reference prevention)', async () => {
|
|
1880
|
+
const prUrl = 'https://github.com/user/repo/pull/77';
|
|
1881
|
+
const prIssue = createMockIssue({
|
|
1882
|
+
url: prUrl,
|
|
1883
|
+
status: 'Preparation',
|
|
1884
|
+
isPr: true,
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1888
|
+
mockIssueRepository.get.mockResolvedValue(prIssue);
|
|
1889
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
1890
|
+
createMockComment({ content: 'From: :robot: Agent report' }),
|
|
1891
|
+
]);
|
|
1892
|
+
mockIssueRepository.getOpenPullRequest.mockResolvedValue({
|
|
1893
|
+
url: prUrl,
|
|
1894
|
+
isConflicted: false,
|
|
1895
|
+
isPassedAllCiJob: true,
|
|
1896
|
+
isCiStateSuccess: true,
|
|
1897
|
+
isResolvedAllReviewComments: true,
|
|
1898
|
+
isBranchOutOfDate: false,
|
|
1899
|
+
missingRequiredCheckNames: [],
|
|
1900
|
+
});
|
|
1901
|
+
|
|
1902
|
+
await useCase.run({
|
|
1903
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
1904
|
+
issueUrl: prUrl,
|
|
1905
|
+
thresholdForAutoReject: 3,
|
|
1906
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
1907
|
+
allowedIssueAuthors: null,
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1879
1913
|
it('should log a warning and skip setDependedIssueUrl when dependedIssueUrlSeparatedByComma is not configured in project', async () => {
|
|
1880
1914
|
const projectWithoutDependedField = createMockProject({
|
|
1881
1915
|
dependedIssueUrlSeparatedByComma: null,
|
|
@@ -360,6 +360,9 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
360
360
|
? await this.resolveOpenPrsForPrItem(issue.url)
|
|
361
361
|
: await this.issueRepository.findRelatedOpenPRs(issue.url);
|
|
362
362
|
for (const pr of openPRs) {
|
|
363
|
+
if (pr.url === issueUrl) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
363
366
|
await this.issueRepository.setDependedIssueUrl(pr.url, project, issueUrl);
|
|
364
367
|
}
|
|
365
368
|
};
|