github-issue-tower-defence-management 1.60.2 → 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.
Files changed (117) hide show
  1. package/.github/workflows/publish.yml +13 -0
  2. package/.github/workflows/test.yml +0 -4
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +53 -10
  5. package/bin/adapter/entry-points/cli/index.js +11 -11
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +3 -22
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -22
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js +56 -0
  12. package/bin/adapter/entry-points/handlers/rotationOrderFileWriter.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/situationFileWriter.js +5 -0
  14. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
  15. package/bin/adapter/proxy/TokenListLoader.js +21 -6
  16. package/bin/adapter/proxy/TokenListLoader.js.map +1 -1
  17. package/bin/adapter/proxy/proxyEntry.js +1 -0
  18. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  19. package/bin/adapter/repositories/BaseGitHubRepository.js +1 -113
  20. package/bin/adapter/repositories/BaseGitHubRepository.js.map +1 -1
  21. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +5 -3
  22. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  23. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +8 -7
  24. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  25. package/bin/domain/usecases/HandleScheduledEventUseCase.js +14 -3
  26. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  27. package/bin/domain/usecases/IssueRejectionEvaluator.js +8 -1
  28. package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
  29. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +5 -1
  30. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  31. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
  32. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  33. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js +32 -1
  34. package/bin/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.js.map +1 -1
  35. package/bin/domain/usecases/StartPreparationUseCase.js +91 -12
  36. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  37. package/package.json +1 -4
  38. package/src/adapter/entry-points/cli/index.test.ts +16 -16
  39. package/src/adapter/entry-points/cli/index.ts +8 -11
  40. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +2 -55
  41. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +1 -11
  42. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -56
  43. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +7 -11
  44. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.test.ts +177 -0
  45. package/src/adapter/entry-points/handlers/rotationOrderFileWriter.ts +20 -0
  46. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +36 -0
  47. package/src/adapter/entry-points/handlers/situationFileWriter.ts +8 -0
  48. package/src/adapter/proxy/TokenListLoader.test.ts +50 -1
  49. package/src/adapter/proxy/TokenListLoader.ts +25 -5
  50. package/src/adapter/proxy/proxyEntry.test.ts +270 -1
  51. package/src/adapter/proxy/proxyEntry.ts +2 -1
  52. package/src/adapter/repositories/BaseGitHubRepository.test.ts +1 -186
  53. package/src/adapter/repositories/BaseGitHubRepository.ts +1 -139
  54. package/src/adapter/repositories/GraphqlProjectRepository.errorHandling.test.ts +0 -1
  55. package/src/adapter/repositories/GraphqlProjectRepository.fetchProjectId.test.ts +4 -1
  56. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +60 -19
  57. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +6 -4
  58. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +23 -13
  59. package/src/adapter/repositories/issue/ApiV3IssueRepository.test.ts +0 -1
  60. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +0 -8
  61. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +0 -1
  62. package/src/domain/entities/ClaudeTokenUsage.ts +1 -0
  63. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
  64. package/src/domain/usecases/HandleScheduledEventUseCase.ts +20 -5
  65. package/src/domain/usecases/IssueRejectionEvaluator.test.ts +153 -0
  66. package/src/domain/usecases/IssueRejectionEvaluator.ts +8 -0
  67. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +175 -31
  68. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +7 -1
  69. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +32 -0
  70. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +39 -5
  71. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -1
  72. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.test.ts +139 -20
  73. package/src/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.ts +62 -2
  74. package/src/domain/usecases/StartPreparationUseCase.test.ts +404 -21
  75. package/src/domain/usecases/StartPreparationUseCase.ts +152 -16
  76. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +16 -0
  77. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  78. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  79. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  80. package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts +3 -0
  81. package/types/adapter/entry-points/handlers/rotationOrderFileWriter.d.ts.map +1 -0
  82. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +1 -0
  83. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
  84. package/types/adapter/proxy/TokenListLoader.d.ts +5 -0
  85. package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -1
  86. package/types/adapter/proxy/proxyEntry.d.ts +2 -1
  87. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  88. package/types/adapter/repositories/BaseGitHubRepository.d.ts +1 -23
  89. package/types/adapter/repositories/BaseGitHubRepository.d.ts.map +1 -1
  90. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  91. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +14 -5
  92. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  93. package/types/domain/entities/ClaudeTokenUsage.d.ts +1 -0
  94. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  95. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -2
  96. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  97. package/types/domain/usecases/IssueRejectionEvaluator.d.ts +1 -1
  98. package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
  99. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  100. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts +5 -2
  101. package/types/domain/usecases/SetWorkflowManagementIssueToStoryUseCase.d.ts.map +1 -1
  102. package/types/domain/usecases/StartPreparationUseCase.d.ts +15 -1
  103. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  104. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +14 -0
  105. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  106. package/bin/adapter/repositories/issue/CheerioIssueRepository.js +0 -136
  107. package/bin/adapter/repositories/issue/CheerioIssueRepository.js.map +0 -1
  108. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js +0 -1606
  109. package/bin/adapter/repositories/issue/InternalGraphqlIssueRepository.js.map +0 -1
  110. package/src/adapter/repositories/issue/CheerioIssueRepository.test.ts +0 -6552
  111. package/src/adapter/repositories/issue/CheerioIssueRepository.ts +0 -142
  112. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.test.ts +0 -118
  113. package/src/adapter/repositories/issue/InternalGraphqlIssueRepository.ts +0 -584
  114. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts +0 -40
  115. package/types/adapter/repositories/issue/CheerioIssueRepository.d.ts.map +0 -1
  116. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts +0 -220
  117. package/types/adapter/repositories/issue/InternalGraphqlIssueRepository.d.ts.map +0 -1
@@ -1,4 +1,28 @@
1
- import { extractToken } from './proxyEntry';
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
- ): void => {
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, jsonFilePath, process.env.GH_TOKEN);
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
  });
@@ -1,33 +1,10 @@
1
- import { promises as fsPromises } from 'fs';
2
- import { serialize } from 'cookie';
3
- import fs from 'fs';
4
1
  import { LocalStorageRepository } from './LocalStorageRepository';
5
- import ky from 'ky';
6
-
7
- interface Cookie {
8
- name: string;
9
- value: string;
10
- domain?: string;
11
- path?: string;
12
- expires?: number;
13
- httpOnly?: boolean;
14
- secure?: boolean;
15
- sameSite?: 'lax' | 'strict' | 'none';
16
- }
17
2
 
18
3
  export class BaseGitHubRepository {
19
- cookie: string | null;
20
4
  constructor(
21
5
  readonly localStorageRepository: LocalStorageRepository,
22
- readonly jsonFilePath: string = './tmp/github.com.cookies.json',
23
6
  readonly ghToken: string = process.env.GH_TOKEN || 'dummy',
24
- readonly ghUserName: string | undefined = process.env.GH_USER_NAME,
25
- readonly ghUserPassword: string | undefined = process.env.GH_USER_PASSWORD,
26
- readonly ghAuthenticatorKey: string | undefined = process.env
27
- .GH_AUTHENTICATOR_KEY,
28
- ) {
29
- this.cookie = null;
30
- }
7
+ ) {}
31
8
  protected extractIssueFromUrl = (
32
9
  issueUrl: string,
33
10
  ): { owner: string; repo: string; issueNumber: number; isIssue: boolean } => {
@@ -46,119 +23,4 @@ export class BaseGitHubRepository {
46
23
  }
47
24
  return { owner, repo, issueNumber, isIssue: pullOrIssue === 'issues' };
48
25
  };
49
-
50
- getCookie = async (): Promise<string> => {
51
- if (!this.cookie) {
52
- this.cookie = await this.createCookieStringFromFile();
53
- }
54
- return this.cookie;
55
- };
56
- createHeader = async (): Promise<Record<string, string>> => {
57
- const cookie = await this.getCookie();
58
- const headers = {
59
- accept:
60
- 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
61
- 'accept-language':
62
- 'en-US,en;q=0.9,es-MX;q=0.8,es;q=0.7,ja-JP;q=0.6,ja;q=0.5',
63
- 'cache-control': 'max-age=0',
64
- 'sec-ch-ua':
65
- '"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"',
66
- 'sec-ch-ua-mobile': '?0',
67
- 'sec-ch-ua-platform': '"Linux"',
68
- 'sec-fetch-dest': 'document',
69
- 'sec-fetch-mode': 'navigate',
70
- 'sec-fetch-site': 'same-origin',
71
- 'sec-fetch-user': '?1',
72
- 'upgrade-insecure-requests': '1',
73
- Referer: 'https://github.com/orgs/community/discussions/30979',
74
- 'Referrer-Policy': 'no-referrer-when-downgrade',
75
- };
76
- return {
77
- ...headers,
78
- cookie: cookie,
79
- };
80
- };
81
- protected createCookieStringFromFile = async (): Promise<string> => {
82
- if (!fs.existsSync(this.jsonFilePath)) {
83
- throw new Error('No cookie file found');
84
- }
85
- const data = await fsPromises.readFile(this.jsonFilePath, {
86
- encoding: 'utf-8',
87
- });
88
- const cookiesData: unknown = JSON.parse(data);
89
- return this.generateCookieHeaderFromJson(cookiesData);
90
- };
91
- protected isCookie = (cookie: object): cookie is Cookie => {
92
- return (
93
- 'name' in cookie &&
94
- typeof cookie.name === 'string' &&
95
- 'value' in cookie &&
96
- typeof cookie.value === 'string' &&
97
- 'domain' in cookie &&
98
- typeof cookie.domain === 'string' &&
99
- 'path' in cookie &&
100
- typeof cookie.path === 'string' &&
101
- 'expires' in cookie &&
102
- typeof cookie.expires === 'number' &&
103
- 'httpOnly' in cookie &&
104
- typeof cookie.httpOnly === 'boolean' &&
105
- 'secure' in cookie &&
106
- typeof cookie.secure === 'boolean' &&
107
- 'sameSite' in cookie &&
108
- typeof cookie.sameSite === 'string' &&
109
- ['lax', 'strict', 'none'].indexOf(cookie.sameSite) !== -1
110
- );
111
- };
112
-
113
- protected generateCookieHeaderFromJson = async (
114
- cookieData: unknown,
115
- ): Promise<string> => {
116
- if (!Array.isArray(cookieData)) {
117
- throw new Error('Invalid cookie array');
118
- }
119
-
120
- const cookies: Cookie[] = cookieData.map((cookieOrig: object) => {
121
- const sameSite =
122
- typeof cookieOrig !== 'object' ||
123
- !('sameSite' in cookieOrig) ||
124
- typeof cookieOrig.sameSite !== 'string'
125
- ? 'none'
126
- : cookieOrig.sameSite.toLowerCase();
127
- const cookie = {
128
- ...cookieOrig,
129
- sameSite,
130
- };
131
-
132
- if (!this.isCookie(cookie)) {
133
- throw new Error(`Invalid cookie properties: ${JSON.stringify(cookie)}`);
134
- }
135
- return cookie;
136
- });
137
- const cookieHeader = cookies
138
- .map((cookie) =>
139
- serialize(cookie.name, cookie.value, {
140
- domain: cookie.domain,
141
- path: cookie.path,
142
- expires: cookie.expires ? new Date(cookie.expires * 1000) : undefined,
143
- httpOnly: cookie.httpOnly,
144
- secure: cookie.secure,
145
- sameSite: cookie.sameSite,
146
- }),
147
- )
148
- .join('; ');
149
- return cookieHeader;
150
- };
151
- refreshCookie = async (): Promise<void> => {
152
- if (!this.ghUserName || !this.ghUserPassword || !this.ghAuthenticatorKey) {
153
- throw new Error(
154
- 'GitHub username, password, and authenticator key must be set',
155
- );
156
- }
157
- const profileUrl = `https://github.com/${this.ghUserName}`;
158
- const headers = await this.createHeader();
159
- const html = await ky.get(profileUrl, { headers }).text();
160
- if (!html.includes(`meta name="user-login" content="${this.ghUserName}"`)) {
161
- throw new Error('Failed to refresh cookie');
162
- }
163
- };
164
26
  }
@@ -28,7 +28,6 @@ describe('GraphqlProjectRepository error handling', () => {
28
28
  mockPost.mockClear();
29
29
  repository = new GraphqlProjectRepository(
30
30
  new LocalStorageRepository(),
31
- '',
32
31
  'dummy-token',
33
32
  );
34
33
  });
@@ -28,7 +28,10 @@ describe('GraphqlProjectRepository.fetchProjectId', () => {
28
28
  beforeEach(() => {
29
29
  jest.useFakeTimers();
30
30
  mockPost.mockReset();
31
- repository = new GraphqlProjectRepository(localStorageRepository, '');
31
+ repository = new GraphqlProjectRepository(
32
+ localStorageRepository,
33
+ 'dummy-token',
34
+ );
32
35
  });
33
36
 
34
37
  afterEach(() => {