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.
Files changed (60) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +20 -1
  3. package/bin/adapter/entry-points/cli/index.js +56 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleDataDelivery.js +155 -0
  6. package/bin/adapter/entry-points/console/consoleDataDelivery.js.map +1 -0
  7. package/bin/adapter/entry-points/console/consoleDoneStore.js +100 -0
  8. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -0
  9. package/bin/adapter/entry-points/console/consoleOperationApi.js +178 -0
  10. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -0
  11. package/bin/adapter/entry-points/console/consoleReadApi.js +119 -0
  12. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -0
  13. package/bin/adapter/entry-points/console/consoleServer.js +147 -3
  14. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  15. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +97 -0
  16. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -0
  17. package/bin/adapter/proxy/RateLimitCache.js +3 -3
  18. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  19. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +3 -0
  20. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  21. package/bin/domain/usecases/OauthTokenSelectUseCase.js +87 -0
  22. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/adapter/entry-points/cli/index.test.ts +94 -0
  25. package/src/adapter/entry-points/cli/index.ts +99 -0
  26. package/src/adapter/entry-points/console/consoleDataDelivery.test.ts +184 -0
  27. package/src/adapter/entry-points/console/consoleDataDelivery.ts +169 -0
  28. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +98 -0
  29. package/src/adapter/entry-points/console/consoleDoneStore.ts +91 -0
  30. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +444 -0
  31. package/src/adapter/entry-points/console/consoleOperationApi.ts +280 -0
  32. package/src/adapter/entry-points/console/consoleReadApi.test.ts +297 -0
  33. package/src/adapter/entry-points/console/consoleReadApi.ts +192 -0
  34. package/src/adapter/entry-points/console/consoleServer.test.ts +269 -0
  35. package/src/adapter/entry-points/console/consoleServer.ts +228 -4
  36. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +204 -0
  37. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +132 -0
  38. package/src/adapter/proxy/RateLimitCache.ts +9 -4
  39. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +34 -0
  40. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +3 -0
  41. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +179 -0
  42. package/src/domain/usecases/OauthTokenSelectUseCase.ts +158 -0
  43. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  44. package/types/adapter/entry-points/console/consoleDataDelivery.d.ts +23 -0
  45. package/types/adapter/entry-points/console/consoleDataDelivery.d.ts.map +1 -0
  46. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +10 -0
  47. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -0
  48. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +18 -0
  49. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -0
  50. package/types/adapter/entry-points/console/consoleReadApi.d.ts +44 -0
  51. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -0
  52. package/types/adapter/entry-points/console/consoleServer.d.ts +8 -1
  53. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  54. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +20 -0
  55. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -0
  56. package/types/adapter/proxy/RateLimitCache.d.ts +2 -2
  57. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  58. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  59. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +35 -0
  60. 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
- export const handleConsoleRequest = (
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
- ): void => {
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
- sendNotFound(response);
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 = (token: string): string =>
43
- path.join(cacheDir(), `${hashToken(token)}.json`);
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 = (token: string): RateLimitSnapshot | null => {
226
- const filePath = cachePathForToken(token);
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
  };