github-issue-tower-defence-management 1.60.1 → 1.63.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +13 -0
- package/.github/workflows/test.yml +0 -4
- package/CHANGELOG.md +14 -0
- package/README.md +53 -10
- package/bin/adapter/entry-points/cli/index.js +11 -11
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +3 -22
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -22
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js +56 -0
- package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/situationFileWriter.js +5 -0
- package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
- package/bin/adapter/proxy/TokenListLoader.js +21 -6
- package/bin/adapter/proxy/TokenListLoader.js.map +1 -1
- package/bin/adapter/proxy/proxyEntry.js +1 -0
- package/bin/adapter/proxy/proxyEntry.js.map +1 -1
- package/bin/adapter/repositories/BaseGitHubRepository.js +1 -113
- package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +5 -3
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +8 -7
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js +19 -9
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +15 -3
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js +8 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +5 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js +32 -1
- package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +91 -12
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/package.json +1 -4
- package/src/adapter/entry-points/cli/index.test.ts +16 -16
- package/src/adapter/entry-points/cli/index.ts +8 -11
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +2 -55
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +1 -11
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -56
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -11
- package/src/adapter/entry-points/handlers/rotationOrderFileWriter.test.ts +177 -0
- package/src/adapter/entry-points/handlers/rotationOrderFileWriter.ts +20 -0
- package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +36 -0
- package/src/adapter/entry-points/handlers/situationFileWriter.ts +8 -0
- package/src/adapter/proxy/TokenListLoader.test.ts +50 -1
- package/src/adapter/proxy/TokenListLoader.ts +25 -5
- package/src/adapter/proxy/proxyEntry.test.ts +270 -1
- package/src/adapter/proxy/proxyEntry.ts +2 -1
- package/src/adapter/repositories/BaseGitHubRepository.test.ts +1 -186
- package/src/adapter/repositories/BaseGitHubRepository.ts +1 -139
- package/src/adapter/repositories/GraphqlProjectRepository.errorHandling.test.ts +0 -1
- package/src/adapter/repositories/GraphqlProjectRepository.fetchProjectId.test.ts +4 -1
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +60 -19
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -4
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +23 -13
- package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +0 -1
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +0 -8
- package/src/adapter/repositories/issue/RestIssueRepository.test.ts +0 -1
- package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +196 -11
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +32 -15
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +21 -5
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +153 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +8 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +175 -31
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +7 -1
- package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +32 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +39 -5
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -1
- package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.test.ts +139 -20
- package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.ts +62 -2
- package/src/domain/usecases/StartPreparationUseCase.test.ts +404 -21
- package/src/domain/usecases/StartPreparationUseCase.ts +152 -16
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +16 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts +3 -0
- package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +1 -0
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
- package/types/adapter/proxy/TokenListLoader.d.ts +5 -0
- package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -1
- package/types/adapter/proxy/proxyEntry.d.ts +2 -1
- package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
- package/types/adapter/repositories/BaseGitHubRepository.d.ts +1 -23
- package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +14 -5
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.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/CreateNewStoryByLabelUseCase.d.ts +4 -2
- package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -2
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts +5 -2
- package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +15 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +14 -0
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/bin/adapter/repositories/issue/CheerioIssueRepository.js +0 -136
- package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +0 -1
- package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js +0 -1606
- package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js.map +0 -1
- package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +0 -6552
- package/src/adapter/repositories/issue/CheerioIssueRepository.ts +0 -142
- package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +0 -118
- package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.ts +0 -584
- package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +0 -40
- package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +0 -1
- package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts +0 -220
- package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts.map +0 -1
|
@@ -50,6 +50,7 @@ const baseParams = {
|
|
|
50
50
|
awaitingQualityCheckStatus: 'Awaiting quality check',
|
|
51
51
|
preparationStatus: 'Preparation',
|
|
52
52
|
awaitingWorkspaceStatus: 'Awaiting workspace',
|
|
53
|
+
failedPreparationStatus: 'Failed Preparation',
|
|
53
54
|
},
|
|
54
55
|
config: {
|
|
55
56
|
maximumPreparingIssuesCount: 6,
|
|
@@ -183,6 +184,7 @@ describe('writeSituationFile', () => {
|
|
|
183
184
|
awaitingQualityCheckStatus: null,
|
|
184
185
|
preparationStatus: null,
|
|
185
186
|
awaitingWorkspaceStatus: null,
|
|
187
|
+
failedPreparationStatus: null,
|
|
186
188
|
},
|
|
187
189
|
issues,
|
|
188
190
|
};
|
|
@@ -207,6 +209,40 @@ describe('writeSituationFile', () => {
|
|
|
207
209
|
expect.any(String),
|
|
208
210
|
expect.stringContaining('"awaitingWorkspaceBlockedByDependency":0'),
|
|
209
211
|
);
|
|
212
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
213
|
+
expect.any(String),
|
|
214
|
+
expect.stringContaining('"failedPreparation":0'),
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('counts failedPreparation correctly from fixture issues', async () => {
|
|
219
|
+
const issues = [
|
|
220
|
+
createIssue({ status: 'Failed Preparation' }),
|
|
221
|
+
createIssue({ status: 'Failed Preparation' }),
|
|
222
|
+
createIssue({ status: 'Preparation' }),
|
|
223
|
+
createIssue({ status: 'Awaiting workspace' }),
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
await writeSituationFile({ ...baseParams, issues });
|
|
227
|
+
|
|
228
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
229
|
+
expect.any(String),
|
|
230
|
+
expect.stringContaining('"failedPreparation":2'),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('sets failedPreparation to 0 when no issues match the failed preparation status', async () => {
|
|
235
|
+
const issues = [
|
|
236
|
+
createIssue({ status: 'Preparation' }),
|
|
237
|
+
createIssue({ status: 'Awaiting workspace' }),
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
await writeSituationFile({ ...baseParams, issues });
|
|
241
|
+
|
|
242
|
+
expect(jest.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
243
|
+
expect.any(String),
|
|
244
|
+
expect.stringContaining('"failedPreparation":0'),
|
|
245
|
+
);
|
|
210
246
|
});
|
|
211
247
|
});
|
|
212
248
|
|
|
@@ -10,6 +10,7 @@ export type SituationFileParams = {
|
|
|
10
10
|
awaitingQualityCheckStatus: string | null;
|
|
11
11
|
preparationStatus: string | null;
|
|
12
12
|
awaitingWorkspaceStatus: string | null;
|
|
13
|
+
failedPreparationStatus: string | null;
|
|
13
14
|
};
|
|
14
15
|
config: {
|
|
15
16
|
maximumPreparingIssuesCount: number | null;
|
|
@@ -104,6 +105,12 @@ export const writeSituationFile = async (
|
|
|
104
105
|
? issues.filter((i) => i.status === statusNames.awaitingWorkspaceStatus)
|
|
105
106
|
: [];
|
|
106
107
|
|
|
108
|
+
const failedPreparation =
|
|
109
|
+
statusNames.failedPreparationStatus !== null
|
|
110
|
+
? issues.filter((i) => i.status === statusNames.failedPreparationStatus)
|
|
111
|
+
.length
|
|
112
|
+
: 0;
|
|
113
|
+
|
|
107
114
|
const awaitingWorkspaceImmediatelyActionable = awaitingWorkspaceIssues.filter(
|
|
108
115
|
isImmediatelyActionable,
|
|
109
116
|
).length;
|
|
@@ -142,6 +149,7 @@ export const writeSituationFile = async (
|
|
|
142
149
|
preparation: preparationIssues.length,
|
|
143
150
|
awaitingWorkspaceImmediatelyActionable,
|
|
144
151
|
awaitingWorkspaceBlockedByDependency,
|
|
152
|
+
failedPreparation,
|
|
145
153
|
},
|
|
146
154
|
processes: {
|
|
147
155
|
runningPreparation,
|
|
@@ -1,7 +1,56 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { loadTokens } from './TokenListLoader';
|
|
4
|
+
import { loadTokenEntries, loadTokens } from './TokenListLoader';
|
|
5
|
+
|
|
6
|
+
describe('loadTokenEntries', () => {
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-entries-loader-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return entries with name and token', () => {
|
|
18
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
19
|
+
fs.writeFileSync(
|
|
20
|
+
filePath,
|
|
21
|
+
JSON.stringify([
|
|
22
|
+
{ name: 'alice', token: 'token-a' },
|
|
23
|
+
{ name: 'bob', token: 'token-b' },
|
|
24
|
+
]),
|
|
25
|
+
);
|
|
26
|
+
expect(loadTokenEntries(filePath)).toEqual([
|
|
27
|
+
{ name: 'alice', token: 'token-a' },
|
|
28
|
+
{ name: 'bob', token: 'token-b' },
|
|
29
|
+
]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should assign a unique positional name when name is absent from entry', () => {
|
|
33
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
34
|
+
fs.writeFileSync(
|
|
35
|
+
filePath,
|
|
36
|
+
JSON.stringify([{ token: 'token-a' }, { token: 'token-b' }]),
|
|
37
|
+
);
|
|
38
|
+
expect(loadTokenEntries(filePath)).toEqual([
|
|
39
|
+
{ name: 'token-1', token: 'token-a' },
|
|
40
|
+
{ name: 'token-2', token: 'token-b' },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return null when file does not exist', () => {
|
|
45
|
+
expect(loadTokenEntries(path.join(tempDir, 'missing.json'))).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return null when every entry is invalid', () => {
|
|
49
|
+
const filePath = path.join(tempDir, 'invalid.json');
|
|
50
|
+
fs.writeFileSync(filePath, JSON.stringify([{ name: 'no-token' }]));
|
|
51
|
+
expect(loadTokenEntries(filePath)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
5
54
|
|
|
6
55
|
describe('TokenListLoader', () => {
|
|
7
56
|
let tempDir: string;
|
|
@@ -15,21 +15,41 @@ const expandHome = (filePath: string): string => {
|
|
|
15
15
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
16
16
|
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
17
|
|
|
18
|
-
export
|
|
18
|
+
export type TokenEntry = {
|
|
19
|
+
name: string;
|
|
20
|
+
token: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const loadTokenEntries = (jsonPath: string): TokenEntry[] | null => {
|
|
19
24
|
const resolved = expandHome(jsonPath);
|
|
20
25
|
if (!fs.existsSync(resolved)) return null;
|
|
21
26
|
try {
|
|
22
27
|
const raw = fs.readFileSync(resolved, 'utf8');
|
|
23
28
|
const parsed: unknown = JSON.parse(raw);
|
|
24
29
|
if (!Array.isArray(parsed)) return null;
|
|
25
|
-
const
|
|
30
|
+
const entries: TokenEntry[] = [];
|
|
26
31
|
for (const entry of parsed) {
|
|
27
|
-
if (
|
|
28
|
-
|
|
32
|
+
if (
|
|
33
|
+
isRecord(entry) &&
|
|
34
|
+
typeof entry.token === 'string' &&
|
|
35
|
+
typeof entry.name === 'string'
|
|
36
|
+
) {
|
|
37
|
+
entries.push({ name: entry.name, token: entry.token });
|
|
38
|
+
} else if (isRecord(entry) && typeof entry.token === 'string') {
|
|
39
|
+
entries.push({
|
|
40
|
+
name: `token-${entries.length + 1}`,
|
|
41
|
+
token: entry.token,
|
|
42
|
+
});
|
|
29
43
|
}
|
|
30
44
|
}
|
|
31
|
-
return
|
|
45
|
+
return entries.length > 0 ? entries : null;
|
|
32
46
|
} catch {
|
|
33
47
|
return null;
|
|
34
48
|
}
|
|
35
49
|
};
|
|
50
|
+
|
|
51
|
+
export const loadTokens = (jsonPath: string): string[] | null => {
|
|
52
|
+
const entries = loadTokenEntries(jsonPath);
|
|
53
|
+
if (entries === null) return null;
|
|
54
|
+
return entries.map((e) => e.token);
|
|
55
|
+
};
|
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import { AddressInfo } from 'net';
|
|
4
|
+
import * as RateLimitCache from './RateLimitCache';
|
|
5
|
+
|
|
6
|
+
type HttpsRequestImpl = (
|
|
7
|
+
options: https.RequestOptions,
|
|
8
|
+
callback?: (response: http.IncomingMessage) => void,
|
|
9
|
+
) => http.ClientRequest;
|
|
10
|
+
|
|
11
|
+
let httpsRequestImpl: HttpsRequestImpl | null = null;
|
|
12
|
+
|
|
13
|
+
jest.mock('https', () => ({
|
|
14
|
+
request: (
|
|
15
|
+
options: https.RequestOptions,
|
|
16
|
+
callback?: (response: http.IncomingMessage) => void,
|
|
17
|
+
): http.ClientRequest => {
|
|
18
|
+
if (httpsRequestImpl === null) {
|
|
19
|
+
throw new Error('https.request implementation not set');
|
|
20
|
+
}
|
|
21
|
+
return httpsRequestImpl(options, callback);
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { extractToken, startProxy } from './proxyEntry';
|
|
2
26
|
|
|
3
27
|
describe('extractToken', () => {
|
|
4
28
|
it('should return the token after a Bearer prefix', () => {
|
|
@@ -46,3 +70,248 @@ describe('extractToken', () => {
|
|
|
46
70
|
expect(Date.now() - startedAt).toBeLessThan(500);
|
|
47
71
|
});
|
|
48
72
|
});
|
|
73
|
+
|
|
74
|
+
interface ClientResponse {
|
|
75
|
+
statusCode: number;
|
|
76
|
+
headers: http.IncomingHttpHeaders;
|
|
77
|
+
body: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isAddressInfo = (
|
|
81
|
+
address: string | AddressInfo | null,
|
|
82
|
+
): address is AddressInfo => address !== null && typeof address === 'object';
|
|
83
|
+
|
|
84
|
+
const addressPort = (server: http.Server): number => {
|
|
85
|
+
const address = server.address();
|
|
86
|
+
if (!isAddressInfo(address)) {
|
|
87
|
+
throw new Error('Expected the server to be listening on a TCP port');
|
|
88
|
+
}
|
|
89
|
+
return address.port;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
describe('startProxy', () => {
|
|
93
|
+
const TOKEN = 'sk-ant-proxy-token';
|
|
94
|
+
|
|
95
|
+
let upstreamServer: http.Server;
|
|
96
|
+
let proxyServer: http.Server;
|
|
97
|
+
let upstreamHandler: (
|
|
98
|
+
request: http.IncomingMessage,
|
|
99
|
+
response: http.ServerResponse,
|
|
100
|
+
) => void;
|
|
101
|
+
let upstreamShouldError = false;
|
|
102
|
+
let upstreamPort = 0;
|
|
103
|
+
let proxyPort = 0;
|
|
104
|
+
let writeRateLimitSpy: jest.SpyInstance;
|
|
105
|
+
let writeModelRateLimitSpy: jest.SpyInstance;
|
|
106
|
+
|
|
107
|
+
const listen = (server: http.Server): Promise<number> =>
|
|
108
|
+
new Promise((resolve) => {
|
|
109
|
+
server.listen(0, '127.0.0.1', () => {
|
|
110
|
+
resolve(addressPort(server));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const closeServer = (server: http.Server): Promise<void> =>
|
|
115
|
+
new Promise((resolve, reject) => {
|
|
116
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const requestThroughProxy = (
|
|
120
|
+
method: string,
|
|
121
|
+
path: string,
|
|
122
|
+
requestBody: string | null,
|
|
123
|
+
): Promise<ClientResponse> =>
|
|
124
|
+
new Promise((resolve, reject) => {
|
|
125
|
+
const clientRequest = http.request(
|
|
126
|
+
{
|
|
127
|
+
host: '127.0.0.1',
|
|
128
|
+
port: proxyPort,
|
|
129
|
+
method,
|
|
130
|
+
path,
|
|
131
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
132
|
+
},
|
|
133
|
+
(response) => {
|
|
134
|
+
const chunks: Uint8Array[] = [];
|
|
135
|
+
response.on('data', (chunk: Buffer) =>
|
|
136
|
+
chunks.push(new Uint8Array(chunk)),
|
|
137
|
+
);
|
|
138
|
+
response.on('end', () => {
|
|
139
|
+
resolve({
|
|
140
|
+
statusCode: response.statusCode ?? 0,
|
|
141
|
+
headers: response.headers,
|
|
142
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
response.on('error', reject);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
clientRequest.on('error', reject);
|
|
149
|
+
if (requestBody !== null) {
|
|
150
|
+
clientRequest.write(requestBody);
|
|
151
|
+
}
|
|
152
|
+
clientRequest.end();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
beforeEach(async () => {
|
|
156
|
+
upstreamShouldError = false;
|
|
157
|
+
upstreamHandler = (_request, response) => {
|
|
158
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
159
|
+
response.end('{}');
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
upstreamServer = http.createServer((request, response) => {
|
|
163
|
+
upstreamHandler(request, response);
|
|
164
|
+
});
|
|
165
|
+
upstreamPort = await listen(upstreamServer);
|
|
166
|
+
|
|
167
|
+
httpsRequestImpl = (options, callback): http.ClientRequest => {
|
|
168
|
+
if (upstreamShouldError) {
|
|
169
|
+
return http.request({
|
|
170
|
+
host: '127.0.0.1',
|
|
171
|
+
port: 1,
|
|
172
|
+
method: options.method,
|
|
173
|
+
path: options.path,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return http.request(
|
|
177
|
+
{
|
|
178
|
+
host: '127.0.0.1',
|
|
179
|
+
port: upstreamPort,
|
|
180
|
+
method: options.method,
|
|
181
|
+
path: options.path,
|
|
182
|
+
headers: options.headers,
|
|
183
|
+
},
|
|
184
|
+
callback,
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
writeRateLimitSpy = jest
|
|
189
|
+
.spyOn(RateLimitCache, 'writeRateLimit')
|
|
190
|
+
.mockImplementation(() => undefined);
|
|
191
|
+
writeModelRateLimitSpy = jest
|
|
192
|
+
.spyOn(RateLimitCache, 'writeModelRateLimit')
|
|
193
|
+
.mockImplementation(() => undefined);
|
|
194
|
+
|
|
195
|
+
proxyPort = await new Promise<number>((resolve) => {
|
|
196
|
+
const probe = http.createServer();
|
|
197
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
198
|
+
const value = addressPort(probe);
|
|
199
|
+
probe.close(() => resolve(value));
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
proxyServer = startProxy(proxyPort);
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(async () => {
|
|
207
|
+
httpsRequestImpl = null;
|
|
208
|
+
writeRateLimitSpy.mockRestore();
|
|
209
|
+
writeModelRateLimitSpy.mockRestore();
|
|
210
|
+
await closeServer(proxyServer);
|
|
211
|
+
await closeServer(upstreamServer);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should forward the full streamed response body to the client while teeing', async () => {
|
|
215
|
+
const payload = 'x'.repeat(300 * 1024);
|
|
216
|
+
upstreamHandler = (_request, response) => {
|
|
217
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
218
|
+
let written = 0;
|
|
219
|
+
const writeNext = (): void => {
|
|
220
|
+
if (written >= payload.length) {
|
|
221
|
+
response.end();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const slice = payload.slice(written, written + 16 * 1024);
|
|
225
|
+
written += slice.length;
|
|
226
|
+
response.write(slice);
|
|
227
|
+
setImmediate(writeNext);
|
|
228
|
+
};
|
|
229
|
+
writeNext();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const response = await requestThroughProxy('POST', '/v1/messages', '{}');
|
|
233
|
+
|
|
234
|
+
expect(response.statusCode).toBe(200);
|
|
235
|
+
expect(response.body).toHaveLength(payload.length);
|
|
236
|
+
expect(response.body).toBe(payload);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should keep forwarding the full stream even after the inspected body exceeds the 1MB cap', async () => {
|
|
240
|
+
const payload = 'y'.repeat(3 * 1024 * 1024);
|
|
241
|
+
const parseSpy = jest.spyOn(RateLimitCache, 'parseModelRateLimitsFromBody');
|
|
242
|
+
upstreamHandler = (_request, response) => {
|
|
243
|
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
244
|
+
let written = 0;
|
|
245
|
+
const writeNext = (): void => {
|
|
246
|
+
if (written >= payload.length) {
|
|
247
|
+
response.end();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const slice = payload.slice(written, written + 64 * 1024);
|
|
251
|
+
written += slice.length;
|
|
252
|
+
response.write(slice);
|
|
253
|
+
setImmediate(writeNext);
|
|
254
|
+
};
|
|
255
|
+
writeNext();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const response = await requestThroughProxy('POST', '/v1/messages', '{}');
|
|
259
|
+
|
|
260
|
+
expect(response.statusCode).toBe(200);
|
|
261
|
+
expect(response.body).toHaveLength(payload.length);
|
|
262
|
+
expect(response.body).toBe(payload);
|
|
263
|
+
expect(parseSpy).toHaveBeenCalledTimes(1);
|
|
264
|
+
const inspectedBody = parseSpy.mock.calls[0][0];
|
|
265
|
+
expect(inspectedBody.length).toBeLessThanOrEqual(1024 * 1024 + 64 * 1024);
|
|
266
|
+
parseSpy.mockRestore();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should parse the body on end and persist model weekly limits via writeModelRateLimit', async () => {
|
|
270
|
+
const resetsAt = 1893456000;
|
|
271
|
+
const body = JSON.stringify({
|
|
272
|
+
type: 'error',
|
|
273
|
+
error: {
|
|
274
|
+
rateLimitType: 'seven_day_sonnet',
|
|
275
|
+
status: 'rejected',
|
|
276
|
+
resetsAt,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
upstreamHandler = (_request, response) => {
|
|
280
|
+
response.writeHead(429, { 'content-type': 'application/json' });
|
|
281
|
+
response.end(body);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const response = await requestThroughProxy('POST', '/v1/messages', '{}');
|
|
285
|
+
|
|
286
|
+
expect(response.statusCode).toBe(429);
|
|
287
|
+
expect(response.body).toBe(body);
|
|
288
|
+
expect(writeModelRateLimitSpy).toHaveBeenCalledWith(TOKEN, {
|
|
289
|
+
seven_day_sonnet: { rejected: true, resetsAt },
|
|
290
|
+
});
|
|
291
|
+
expect(writeModelRateLimitSpy).toHaveBeenCalledTimes(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should forward non-SSE responses without crashing', async () => {
|
|
295
|
+
upstreamHandler = (_request, response) => {
|
|
296
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
297
|
+
response.end('{"ok":true}');
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const response = await requestThroughProxy('GET', '/v1/models', null);
|
|
301
|
+
|
|
302
|
+
expect(response.statusCode).toBe(200);
|
|
303
|
+
expect(response.body).toBe('{"ok":true}');
|
|
304
|
+
expect(writeModelRateLimitSpy).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(writeModelRateLimitSpy).toHaveBeenCalledWith(TOKEN, {});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should respond with 502 when the upstream request errors', async () => {
|
|
309
|
+
upstreamShouldError = true;
|
|
310
|
+
|
|
311
|
+
const response = await requestThroughProxy('GET', '/v1/models', null);
|
|
312
|
+
|
|
313
|
+
expect(response.statusCode).toBe(502);
|
|
314
|
+
expect(response.body).toBe('Upstream error');
|
|
315
|
+
expect(writeModelRateLimitSpy).not.toHaveBeenCalled();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -32,7 +32,7 @@ const extractToken = (
|
|
|
32
32
|
const startProxy = (
|
|
33
33
|
port: number,
|
|
34
34
|
claudeMessageResponseRepository: ClaudeMessageResponseRepository | null = null,
|
|
35
|
-
):
|
|
35
|
+
): http.Server => {
|
|
36
36
|
const server = http.createServer((clientRequest, clientResponse) => {
|
|
37
37
|
const token = extractToken(clientRequest.headers['authorization']);
|
|
38
38
|
const upstreamHeaders: Record<string, string | string[] | undefined> = {
|
|
@@ -106,6 +106,7 @@ const startProxy = (
|
|
|
106
106
|
server.listen(port, '127.0.0.1', () => {
|
|
107
107
|
console.log(`tdpm proxy listening on 127.0.0.1:${port}`);
|
|
108
108
|
});
|
|
109
|
+
return server;
|
|
109
110
|
};
|
|
110
111
|
|
|
111
112
|
if (require.main === module) {
|
|
@@ -1,56 +1,14 @@
|
|
|
1
|
-
const mockKyGetText = jest.fn<Promise<string>, []>();
|
|
2
|
-
const mockKyGet = jest.fn(() => ({ text: mockKyGetText }));
|
|
3
|
-
|
|
4
|
-
jest.mock('ky', () => ({
|
|
5
|
-
default: {
|
|
6
|
-
get: mockKyGet,
|
|
7
|
-
post: jest.fn(),
|
|
8
|
-
put: jest.fn(),
|
|
9
|
-
patch: jest.fn(),
|
|
10
|
-
delete: jest.fn(),
|
|
11
|
-
extend: jest.fn(),
|
|
12
|
-
create: jest.fn(),
|
|
13
|
-
stop: jest.fn(),
|
|
14
|
-
},
|
|
15
|
-
__esModule: true,
|
|
16
|
-
}));
|
|
17
|
-
|
|
18
|
-
import fs from 'fs';
|
|
19
1
|
import { BaseGitHubRepository } from './BaseGitHubRepository';
|
|
20
|
-
import resetAllMocks = jest.resetAllMocks;
|
|
21
2
|
import { LocalStorageRepository } from './LocalStorageRepository';
|
|
22
3
|
describe('BaseGitHubRepository', () => {
|
|
23
|
-
const jsonFilePath = './tmp/github.com.cookies.json';
|
|
24
4
|
const localStorageRepository = new LocalStorageRepository();
|
|
25
5
|
class TestGitHubRepository extends BaseGitHubRepository {
|
|
26
6
|
constructor() {
|
|
27
|
-
super(localStorageRepository,
|
|
7
|
+
super(localStorageRepository, process.env.GH_TOKEN);
|
|
28
8
|
}
|
|
29
9
|
extractIssueFromUrlPublic = this.extractIssueFromUrl;
|
|
30
|
-
createHeaderPublic = this.createHeader;
|
|
31
|
-
createCookieStringFromFilePublic = this.createCookieStringFromFile;
|
|
32
|
-
isCookiePublic = this.isCookie;
|
|
33
10
|
}
|
|
34
11
|
const baseGitHubRepository: TestGitHubRepository = new TestGitHubRepository();
|
|
35
|
-
beforeAll(() => {
|
|
36
|
-
resetAllMocks();
|
|
37
|
-
const cookies = [
|
|
38
|
-
{
|
|
39
|
-
name: 'name',
|
|
40
|
-
value: 'value',
|
|
41
|
-
domain: 'domain',
|
|
42
|
-
path: 'path',
|
|
43
|
-
expires: 1,
|
|
44
|
-
httpOnly: true,
|
|
45
|
-
secure: true,
|
|
46
|
-
sameSite: 'Lax',
|
|
47
|
-
},
|
|
48
|
-
];
|
|
49
|
-
fs.writeFileSync(jsonFilePath, JSON.stringify(cookies));
|
|
50
|
-
});
|
|
51
|
-
afterAll(() => {
|
|
52
|
-
fs.rmSync(jsonFilePath);
|
|
53
|
-
});
|
|
54
12
|
|
|
55
13
|
describe('extractIssueFromUrl', () => {
|
|
56
14
|
it('should return issue number', () => {
|
|
@@ -65,147 +23,4 @@ describe('BaseGitHubRepository', () => {
|
|
|
65
23
|
});
|
|
66
24
|
});
|
|
67
25
|
});
|
|
68
|
-
|
|
69
|
-
describe('createHeader', () => {
|
|
70
|
-
it('should return headers with cookie', async () => {
|
|
71
|
-
const headers = await baseGitHubRepository.createHeaderPublic();
|
|
72
|
-
expect(headers).toHaveProperty('cookie');
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('createCookieStringFromFile', () => {
|
|
77
|
-
it('should return cookie string', async () => {
|
|
78
|
-
const cookie =
|
|
79
|
-
await baseGitHubRepository.createCookieStringFromFilePublic();
|
|
80
|
-
expect(cookie).toEqual(
|
|
81
|
-
'name=value; Domain=domain; Path=path; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; Secure; SameSite=Lax',
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
describe('isCookie', () => {
|
|
87
|
-
it('should return true if cookie is valid', () => {
|
|
88
|
-
const cookie = {
|
|
89
|
-
name: 'name',
|
|
90
|
-
value: 'value',
|
|
91
|
-
domain: 'domain',
|
|
92
|
-
path: 'path',
|
|
93
|
-
expires: 1,
|
|
94
|
-
httpOnly: true,
|
|
95
|
-
secure: true,
|
|
96
|
-
sameSite: 'lax',
|
|
97
|
-
};
|
|
98
|
-
expect(baseGitHubRepository.isCookiePublic(cookie)).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('should return false if cookie is invalid', () => {
|
|
102
|
-
const cookie = {
|
|
103
|
-
name: 'name',
|
|
104
|
-
value: 'value',
|
|
105
|
-
domain: 'domain',
|
|
106
|
-
path: 'path',
|
|
107
|
-
expires: 1,
|
|
108
|
-
httpOnly: true,
|
|
109
|
-
secure: true,
|
|
110
|
-
};
|
|
111
|
-
expect(baseGitHubRepository.isCookiePublic(cookie)).toBe(false);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe('refreshCookie', () => {
|
|
116
|
-
const localStorageRepositoryForRefresh = new LocalStorageRepository();
|
|
117
|
-
const refreshCookieJsonFilePath =
|
|
118
|
-
'./tmp/refresh-test-github.com.cookies.json';
|
|
119
|
-
const ghUserName = 'testuser';
|
|
120
|
-
class RefreshTestRepository extends BaseGitHubRepository {
|
|
121
|
-
constructor() {
|
|
122
|
-
super(
|
|
123
|
-
localStorageRepositoryForRefresh,
|
|
124
|
-
refreshCookieJsonFilePath,
|
|
125
|
-
'dummy-token',
|
|
126
|
-
ghUserName,
|
|
127
|
-
'dummy-password',
|
|
128
|
-
'dummy-authenticator-key',
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const validCookieJson = JSON.stringify([
|
|
134
|
-
{
|
|
135
|
-
name: 'name',
|
|
136
|
-
value: 'value',
|
|
137
|
-
domain: 'domain',
|
|
138
|
-
path: 'path',
|
|
139
|
-
expires: 1,
|
|
140
|
-
httpOnly: true,
|
|
141
|
-
secure: true,
|
|
142
|
-
sameSite: 'Lax',
|
|
143
|
-
},
|
|
144
|
-
]);
|
|
145
|
-
|
|
146
|
-
beforeEach(() => {
|
|
147
|
-
mockKyGet.mockReset().mockReturnValue({ text: mockKyGetText });
|
|
148
|
-
mockKyGetText.mockReset();
|
|
149
|
-
fs.writeFileSync(refreshCookieJsonFilePath, validCookieJson);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
afterEach(() => {
|
|
153
|
-
if (fs.existsSync(refreshCookieJsonFilePath)) {
|
|
154
|
-
fs.rmSync(refreshCookieJsonFilePath);
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('should return when HTML contains user-login meta tag for current user (logged in)', async () => {
|
|
159
|
-
const repository = new RefreshTestRepository();
|
|
160
|
-
mockKyGetText.mockResolvedValueOnce(
|
|
161
|
-
`<html><head><meta name="user-login" content="${ghUserName}"></head><body><h1>${ghUserName}</h1></body></html>`,
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
await expect(repository.refreshCookie()).resolves.toBeUndefined();
|
|
165
|
-
|
|
166
|
-
expect(mockKyGet).toHaveBeenCalledWith(
|
|
167
|
-
`https://github.com/${ghUserName}`,
|
|
168
|
-
expect.anything(),
|
|
169
|
-
);
|
|
170
|
-
expect(mockKyGet).toHaveBeenCalledTimes(1);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('should fail when HTML contains username in content but not in user-login meta tag (not logged in)', async () => {
|
|
174
|
-
const repository = new RefreshTestRepository();
|
|
175
|
-
const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body><h1>${ghUserName}</h1><p>Public profile</p></body></html>`;
|
|
176
|
-
mockKyGetText.mockResolvedValueOnce(notLoggedInHtml);
|
|
177
|
-
|
|
178
|
-
await expect(repository.refreshCookie()).rejects.toThrow(
|
|
179
|
-
'Failed to refresh cookie',
|
|
180
|
-
);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('should use profile page URL not homepage to check authentication', async () => {
|
|
184
|
-
const repository = new RefreshTestRepository();
|
|
185
|
-
mockKyGetText.mockResolvedValueOnce(
|
|
186
|
-
`<html><head><meta name="user-login" content="${ghUserName}"></head><body></body></html>`,
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
await repository.refreshCookie();
|
|
190
|
-
|
|
191
|
-
expect(mockKyGet).toHaveBeenCalledWith(
|
|
192
|
-
`https://github.com/${ghUserName}`,
|
|
193
|
-
expect.anything(),
|
|
194
|
-
);
|
|
195
|
-
expect(mockKyGet).not.toHaveBeenCalledWith(
|
|
196
|
-
'https://github.com',
|
|
197
|
-
expect.anything(),
|
|
198
|
-
);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('should throw when the authentication check fails', async () => {
|
|
202
|
-
const repository = new RefreshTestRepository();
|
|
203
|
-
const notLoggedInHtml = `<html><head><meta name="user-login" content=""></head><body></body></html>`;
|
|
204
|
-
mockKyGetText.mockResolvedValueOnce(notLoggedInHtml);
|
|
205
|
-
|
|
206
|
-
await expect(repository.refreshCookie()).rejects.toThrow(
|
|
207
|
-
'Failed to refresh cookie',
|
|
208
|
-
);
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
26
|
});
|