github-issue-tower-defence-management 1.173.0 → 1.173.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/entry-points/cli/index.js +1 -1
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/consoleReadApi.js +126 -48
- package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/{index-ZYMkO0fm.js → index-BcnLfodS.js} +1 -1
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js +12 -5
- package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +19 -17
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/githubRateLimitRetry.js +8 -1
- package/bin/adapter/repositories/issue/githubRateLimitRetry.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +2 -0
- package/src/adapter/entry-points/console/consoleReadApi.test.ts +254 -0
- package/src/adapter/entry-points/console/consoleReadApi.ts +133 -55
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +10 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +14 -1
- package/src/adapter/entry-points/console/ui-dist/assets/{index-ZYMkO0fm.js → index-BcnLfodS.js} +1 -1
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.test.ts +35 -0
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +71 -1
- package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +17 -4
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +47 -19
- package/src/adapter/repositories/issue/githubRateLimitRetry.ts +4 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/consoleReadApi.d.ts +2 -0
- package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -1
- package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +2 -1
- package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/githubRateLimitRetry.d.ts +3 -0
- package/types/adapter/repositories/issue/githubRateLimitRetry.d.ts.map +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mock } from 'jest-mock-extended';
|
|
2
2
|
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
3
|
+
import { GitHubRateLimitError } from '../../repositories/issue/githubRateLimitRetry';
|
|
3
4
|
import {
|
|
4
5
|
ISSUE_TITLE_CACHE_TTL_MS,
|
|
5
6
|
IssueTitleStateCache,
|
|
@@ -15,6 +16,9 @@ import {
|
|
|
15
16
|
} from './consoleReadApi';
|
|
16
17
|
|
|
17
18
|
describe('consoleReadApi', () => {
|
|
19
|
+
const RATE_LIMIT_ERROR_MESSAGE =
|
|
20
|
+
'Failed to fetch body for https://github.com/o/r/issues/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
21
|
+
|
|
18
22
|
describe('handleItemBody', () => {
|
|
19
23
|
it('returns 400 when url is missing', async () => {
|
|
20
24
|
const issueRepository = mock<IssueRepository>();
|
|
@@ -35,6 +39,29 @@ describe('consoleReadApi', () => {
|
|
|
35
39
|
'https://github.com/o/r/issues/1',
|
|
36
40
|
);
|
|
37
41
|
});
|
|
42
|
+
|
|
43
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error', async () => {
|
|
44
|
+
const issueRepository = mock<IssueRepository>();
|
|
45
|
+
issueRepository.getIssueOrPullRequestBody.mockRejectedValue(
|
|
46
|
+
new GitHubRateLimitError(RATE_LIMIT_ERROR_MESSAGE),
|
|
47
|
+
);
|
|
48
|
+
const response = await handleItemBody(
|
|
49
|
+
issueRepository,
|
|
50
|
+
'https://github.com/o/r/issues/1',
|
|
51
|
+
);
|
|
52
|
+
expect(response.statusCode).toBe(429);
|
|
53
|
+
expect(response.body).toEqual({ error: RATE_LIMIT_ERROR_MESSAGE });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
57
|
+
const issueRepository = mock<IssueRepository>();
|
|
58
|
+
issueRepository.getIssueOrPullRequestBody.mockRejectedValue(
|
|
59
|
+
new Error('Network timeout'),
|
|
60
|
+
);
|
|
61
|
+
await expect(
|
|
62
|
+
handleItemBody(issueRepository, 'https://github.com/o/r/issues/1'),
|
|
63
|
+
).rejects.toThrow('Network timeout');
|
|
64
|
+
});
|
|
38
65
|
});
|
|
39
66
|
|
|
40
67
|
describe('handleComments', () => {
|
|
@@ -68,6 +95,31 @@ describe('consoleReadApi', () => {
|
|
|
68
95
|
const response = await handleComments(issueRepository, null);
|
|
69
96
|
expect(response.statusCode).toBe(400);
|
|
70
97
|
});
|
|
98
|
+
|
|
99
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error', async () => {
|
|
100
|
+
const issueRepository = mock<IssueRepository>();
|
|
101
|
+
const rateLimitMessage =
|
|
102
|
+
'Failed to fetch comments for https://github.com/o/r/issues/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
103
|
+
issueRepository.getIssueOrPullRequestComments.mockRejectedValue(
|
|
104
|
+
new GitHubRateLimitError(rateLimitMessage),
|
|
105
|
+
);
|
|
106
|
+
const response = await handleComments(
|
|
107
|
+
issueRepository,
|
|
108
|
+
'https://github.com/o/r/issues/1',
|
|
109
|
+
);
|
|
110
|
+
expect(response.statusCode).toBe(429);
|
|
111
|
+
expect(response.body).toEqual({ error: rateLimitMessage });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
115
|
+
const issueRepository = mock<IssueRepository>();
|
|
116
|
+
issueRepository.getIssueOrPullRequestComments.mockRejectedValue(
|
|
117
|
+
new Error('Network timeout'),
|
|
118
|
+
);
|
|
119
|
+
await expect(
|
|
120
|
+
handleComments(issueRepository, 'https://github.com/o/r/issues/1'),
|
|
121
|
+
).rejects.toThrow('Network timeout');
|
|
122
|
+
});
|
|
71
123
|
});
|
|
72
124
|
|
|
73
125
|
describe('handlePrFiles', () => {
|
|
@@ -130,6 +182,31 @@ describe('consoleReadApi', () => {
|
|
|
130
182
|
const response = await handlePrFiles(issueRepository, null);
|
|
131
183
|
expect(response.statusCode).toBe(400);
|
|
132
184
|
});
|
|
185
|
+
|
|
186
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error', async () => {
|
|
187
|
+
const issueRepository = mock<IssueRepository>();
|
|
188
|
+
const rateLimitMessage =
|
|
189
|
+
'Failed to fetch PR detail for https://github.com/o/r/pull/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
190
|
+
issueRepository.getPullRequestDetail.mockRejectedValue(
|
|
191
|
+
new GitHubRateLimitError(rateLimitMessage),
|
|
192
|
+
);
|
|
193
|
+
const response = await handlePrFiles(
|
|
194
|
+
issueRepository,
|
|
195
|
+
'https://github.com/o/r/pull/1',
|
|
196
|
+
);
|
|
197
|
+
expect(response.statusCode).toBe(429);
|
|
198
|
+
expect(response.body).toEqual({ error: rateLimitMessage });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
202
|
+
const issueRepository = mock<IssueRepository>();
|
|
203
|
+
issueRepository.getPullRequestDetail.mockRejectedValue(
|
|
204
|
+
new Error('Network timeout'),
|
|
205
|
+
);
|
|
206
|
+
await expect(
|
|
207
|
+
handlePrFiles(issueRepository, 'https://github.com/o/r/pull/1'),
|
|
208
|
+
).rejects.toThrow('Network timeout');
|
|
209
|
+
});
|
|
133
210
|
});
|
|
134
211
|
|
|
135
212
|
describe('handlePrCommits', () => {
|
|
@@ -165,6 +242,31 @@ describe('consoleReadApi', () => {
|
|
|
165
242
|
const response = await handlePrCommits(issueRepository, null);
|
|
166
243
|
expect(response.statusCode).toBe(400);
|
|
167
244
|
});
|
|
245
|
+
|
|
246
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error', async () => {
|
|
247
|
+
const issueRepository = mock<IssueRepository>();
|
|
248
|
+
const rateLimitMessage =
|
|
249
|
+
'Failed to fetch commits for https://github.com/o/r/pull/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
250
|
+
issueRepository.getPullRequestCommits.mockRejectedValue(
|
|
251
|
+
new GitHubRateLimitError(rateLimitMessage),
|
|
252
|
+
);
|
|
253
|
+
const response = await handlePrCommits(
|
|
254
|
+
issueRepository,
|
|
255
|
+
'https://github.com/o/r/pull/1',
|
|
256
|
+
);
|
|
257
|
+
expect(response.statusCode).toBe(429);
|
|
258
|
+
expect(response.body).toEqual({ error: rateLimitMessage });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
262
|
+
const issueRepository = mock<IssueRepository>();
|
|
263
|
+
issueRepository.getPullRequestCommits.mockRejectedValue(
|
|
264
|
+
new Error('Network timeout'),
|
|
265
|
+
);
|
|
266
|
+
await expect(
|
|
267
|
+
handlePrCommits(issueRepository, 'https://github.com/o/r/pull/1'),
|
|
268
|
+
).rejects.toThrow('Network timeout');
|
|
269
|
+
});
|
|
168
270
|
});
|
|
169
271
|
|
|
170
272
|
describe('handleRelatedPrs', () => {
|
|
@@ -228,6 +330,31 @@ describe('consoleReadApi', () => {
|
|
|
228
330
|
const response = await handleRelatedPrs(issueRepository, null);
|
|
229
331
|
expect(response.statusCode).toBe(400);
|
|
230
332
|
});
|
|
333
|
+
|
|
334
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error', async () => {
|
|
335
|
+
const issueRepository = mock<IssueRepository>();
|
|
336
|
+
const rateLimitMessage =
|
|
337
|
+
'Failed to fetch related PRs for https://github.com/o/r/issues/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
338
|
+
issueRepository.findRelatedOpenPRs.mockRejectedValue(
|
|
339
|
+
new GitHubRateLimitError(rateLimitMessage),
|
|
340
|
+
);
|
|
341
|
+
const response = await handleRelatedPrs(
|
|
342
|
+
issueRepository,
|
|
343
|
+
'https://github.com/o/r/issues/1',
|
|
344
|
+
);
|
|
345
|
+
expect(response.statusCode).toBe(429);
|
|
346
|
+
expect(response.body).toEqual({ error: rateLimitMessage });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
350
|
+
const issueRepository = mock<IssueRepository>();
|
|
351
|
+
issueRepository.findRelatedOpenPRs.mockRejectedValue(
|
|
352
|
+
new Error('Network timeout'),
|
|
353
|
+
);
|
|
354
|
+
await expect(
|
|
355
|
+
handleRelatedPrs(issueRepository, 'https://github.com/o/r/issues/1'),
|
|
356
|
+
).rejects.toThrow('Network timeout');
|
|
357
|
+
});
|
|
231
358
|
});
|
|
232
359
|
|
|
233
360
|
describe('handleIssueTitle with the TTL cache', () => {
|
|
@@ -350,6 +477,61 @@ describe('consoleReadApi', () => {
|
|
|
350
477
|
1,
|
|
351
478
|
);
|
|
352
479
|
});
|
|
480
|
+
|
|
481
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error and cache is empty', async () => {
|
|
482
|
+
const issueRepository = mock<IssueRepository>();
|
|
483
|
+
const rateLimitMessage =
|
|
484
|
+
'Failed to fetch state for https://github.com/o/r/issues/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
485
|
+
issueRepository.getIssueOrPullRequestState.mockRejectedValue(
|
|
486
|
+
new GitHubRateLimitError(rateLimitMessage),
|
|
487
|
+
);
|
|
488
|
+
const cache = new IssueTitleStateCache(() => 0);
|
|
489
|
+
const response = await handleIssueTitle(
|
|
490
|
+
issueRepository,
|
|
491
|
+
cache,
|
|
492
|
+
'https://github.com/o/r/issues/1',
|
|
493
|
+
);
|
|
494
|
+
expect(response.statusCode).toBe(429);
|
|
495
|
+
expect(response.body).toEqual({ error: rateLimitMessage });
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it('serves the stale cached value when the repository throws a GitHub rate limit error and a cache entry exists', async () => {
|
|
499
|
+
const issueRepository = mock<IssueRepository>();
|
|
500
|
+
const staleState = {
|
|
501
|
+
state: 'OPEN',
|
|
502
|
+
merged: false,
|
|
503
|
+
isPullRequest: false,
|
|
504
|
+
title: 'Stale title',
|
|
505
|
+
};
|
|
506
|
+
const rateLimitMessage =
|
|
507
|
+
'Failed to fetch state for https://github.com/o/r/issues/1: HTTP 403 GitHub rate limit exceeded, please retry shortly';
|
|
508
|
+
issueRepository.getIssueOrPullRequestState
|
|
509
|
+
.mockResolvedValueOnce(staleState)
|
|
510
|
+
.mockRejectedValue(new GitHubRateLimitError(rateLimitMessage));
|
|
511
|
+
let now = 0;
|
|
512
|
+
const cache = new IssueTitleStateCache(() => now);
|
|
513
|
+
const url = 'https://github.com/o/r/issues/1';
|
|
514
|
+
await handleIssueTitle(issueRepository, cache, url);
|
|
515
|
+
now = ISSUE_TITLE_CACHE_TTL_MS + 1;
|
|
516
|
+
const response = await handleIssueTitle(issueRepository, cache, url);
|
|
517
|
+
expect(response.statusCode).toBe(200);
|
|
518
|
+
expect(response.body).toEqual(staleState);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
522
|
+
const issueRepository = mock<IssueRepository>();
|
|
523
|
+
issueRepository.getIssueOrPullRequestState.mockRejectedValue(
|
|
524
|
+
new Error('Network timeout'),
|
|
525
|
+
);
|
|
526
|
+
const cache = new IssueTitleStateCache(() => 0);
|
|
527
|
+
await expect(
|
|
528
|
+
handleIssueTitle(
|
|
529
|
+
issueRepository,
|
|
530
|
+
cache,
|
|
531
|
+
'https://github.com/o/r/issues/1',
|
|
532
|
+
),
|
|
533
|
+
).rejects.toThrow('Network timeout');
|
|
534
|
+
});
|
|
353
535
|
});
|
|
354
536
|
|
|
355
537
|
describe('handlePullRequestStatus with the TTL cache', () => {
|
|
@@ -456,5 +638,77 @@ describe('consoleReadApi', () => {
|
|
|
456
638
|
2,
|
|
457
639
|
);
|
|
458
640
|
});
|
|
641
|
+
|
|
642
|
+
it('returns 429 with the error message when the repository throws a GitHub rate limit error and cache is empty', async () => {
|
|
643
|
+
const issueRepository = mock<IssueRepository>();
|
|
644
|
+
const rateLimitMessage =
|
|
645
|
+
'Failed to fetch CI status for https://github.com/o/r/pull/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
646
|
+
issueRepository.getOpenPullRequestCiStatus.mockRejectedValue(
|
|
647
|
+
new GitHubRateLimitError(rateLimitMessage),
|
|
648
|
+
);
|
|
649
|
+
const cache = new PullRequestStatusCache(() => 0);
|
|
650
|
+
const response = await handlePullRequestStatus(
|
|
651
|
+
issueRepository,
|
|
652
|
+
cache,
|
|
653
|
+
'https://github.com/o/r/pull/1',
|
|
654
|
+
);
|
|
655
|
+
expect(response.statusCode).toBe(429);
|
|
656
|
+
expect(response.body).toEqual({ error: rateLimitMessage });
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it('serves the stale cached value when the repository throws a GitHub rate limit error and a cache entry exists', async () => {
|
|
660
|
+
const issueRepository = mock<IssueRepository>();
|
|
661
|
+
const openPullRequest = {
|
|
662
|
+
url: 'https://github.com/o/r/pull/1',
|
|
663
|
+
isConflicted: false,
|
|
664
|
+
mergeable: 'MERGEABLE',
|
|
665
|
+
isPassedAllCiJob: true,
|
|
666
|
+
isCiStateSuccess: true,
|
|
667
|
+
isBranchOutOfDate: false,
|
|
668
|
+
missingRequiredCheckNames: [],
|
|
669
|
+
};
|
|
670
|
+
const rateLimitMessage =
|
|
671
|
+
'Failed to fetch CI status for https://github.com/o/r/pull/1: HTTP 403 GitHub rate limit exceeded, please retry shortly';
|
|
672
|
+
issueRepository.getOpenPullRequestCiStatus
|
|
673
|
+
.mockResolvedValueOnce(openPullRequest)
|
|
674
|
+
.mockRejectedValue(new GitHubRateLimitError(rateLimitMessage));
|
|
675
|
+
let now = 0;
|
|
676
|
+
const cache = new PullRequestStatusCache(() => now);
|
|
677
|
+
const url = 'https://github.com/o/r/pull/1';
|
|
678
|
+
await handlePullRequestStatus(issueRepository, cache, url);
|
|
679
|
+
now = PULL_REQUEST_STATUS_CACHE_TTL_MS + 1;
|
|
680
|
+
const response = await handlePullRequestStatus(
|
|
681
|
+
issueRepository,
|
|
682
|
+
cache,
|
|
683
|
+
url,
|
|
684
|
+
);
|
|
685
|
+
expect(response.statusCode).toBe(200);
|
|
686
|
+
expect(response.body).toEqual({
|
|
687
|
+
found: true,
|
|
688
|
+
status: {
|
|
689
|
+
isConflicted: false,
|
|
690
|
+
mergeableStatus: 'MERGEABLE',
|
|
691
|
+
isPassedAllCiJob: true,
|
|
692
|
+
isCiStateSuccess: true,
|
|
693
|
+
isBranchOutOfDate: false,
|
|
694
|
+
missingRequiredCheckNames: [],
|
|
695
|
+
},
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('re-throws non-rate-limit errors from the repository', async () => {
|
|
700
|
+
const issueRepository = mock<IssueRepository>();
|
|
701
|
+
issueRepository.getOpenPullRequestCiStatus.mockRejectedValue(
|
|
702
|
+
new Error('Network timeout'),
|
|
703
|
+
);
|
|
704
|
+
const cache = new PullRequestStatusCache(() => 0);
|
|
705
|
+
await expect(
|
|
706
|
+
handlePullRequestStatus(
|
|
707
|
+
issueRepository,
|
|
708
|
+
cache,
|
|
709
|
+
'https://github.com/o/r/pull/1',
|
|
710
|
+
),
|
|
711
|
+
).rejects.toThrow('Network timeout');
|
|
712
|
+
});
|
|
459
713
|
});
|
|
460
714
|
});
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
IssueComment,
|
|
4
4
|
PullRequestCommit,
|
|
5
5
|
} from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
6
|
+
import { GitHubRateLimitError } from '../../repositories/issue/githubRateLimitRetry';
|
|
6
7
|
|
|
7
8
|
export const ISSUE_TITLE_CACHE_TTL_MS = 300 * 1000;
|
|
8
9
|
|
|
@@ -72,6 +73,11 @@ export class IssueTitleStateCache {
|
|
|
72
73
|
return entry.state;
|
|
73
74
|
};
|
|
74
75
|
|
|
76
|
+
getStale = (url: string): IssueOrPullRequestState | null => {
|
|
77
|
+
const entry = this.entries.get(url);
|
|
78
|
+
return entry?.state ?? null;
|
|
79
|
+
};
|
|
80
|
+
|
|
75
81
|
set = (url: string, state: IssueOrPullRequestState): void => {
|
|
76
82
|
this.entries.set(url, { state, fetchedAtMs: this.nowMs() });
|
|
77
83
|
};
|
|
@@ -93,6 +99,11 @@ export class PullRequestStatusCache {
|
|
|
93
99
|
return entry.status;
|
|
94
100
|
};
|
|
95
101
|
|
|
102
|
+
getStale = (url: string): PullRequestStatusResponse | null => {
|
|
103
|
+
const entry = this.entries.get(url);
|
|
104
|
+
return entry?.status ?? null;
|
|
105
|
+
};
|
|
106
|
+
|
|
96
107
|
set = (url: string, status: PullRequestStatusResponse): void => {
|
|
97
108
|
this.entries.set(url, { status, fetchedAtMs: this.nowMs() });
|
|
98
109
|
};
|
|
@@ -103,6 +114,10 @@ export type ConsoleReadApiResponse = {
|
|
|
103
114
|
body: unknown;
|
|
104
115
|
};
|
|
105
116
|
|
|
117
|
+
const isGitHubRateLimitError = (
|
|
118
|
+
error: unknown,
|
|
119
|
+
): error is GitHubRateLimitError => error instanceof GitHubRateLimitError;
|
|
120
|
+
|
|
106
121
|
const badRequest = (message: string): ConsoleReadApiResponse => ({
|
|
107
122
|
statusCode: 400,
|
|
108
123
|
body: { error: message },
|
|
@@ -113,6 +128,11 @@ const ok = (body: unknown): ConsoleReadApiResponse => ({
|
|
|
113
128
|
body,
|
|
114
129
|
});
|
|
115
130
|
|
|
131
|
+
const rateLimited = (error: Error): ConsoleReadApiResponse => ({
|
|
132
|
+
statusCode: 429,
|
|
133
|
+
body: { error: error.message },
|
|
134
|
+
});
|
|
135
|
+
|
|
116
136
|
export type RelatedPullRequestWithSummary = {
|
|
117
137
|
url: string;
|
|
118
138
|
branchName: string | null;
|
|
@@ -160,8 +180,15 @@ export const handleItemBody = async (
|
|
|
160
180
|
if (!url) {
|
|
161
181
|
return badRequest('url query parameter is required');
|
|
162
182
|
}
|
|
163
|
-
|
|
164
|
-
|
|
183
|
+
try {
|
|
184
|
+
const body = await issueRepository.getIssueOrPullRequestBody(url);
|
|
185
|
+
return ok({ body });
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (isGitHubRateLimitError(error)) {
|
|
188
|
+
return rateLimited(error);
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
165
192
|
};
|
|
166
193
|
|
|
167
194
|
export const handleComments = async (
|
|
@@ -171,8 +198,15 @@ export const handleComments = async (
|
|
|
171
198
|
if (!url) {
|
|
172
199
|
return badRequest('url query parameter is required');
|
|
173
200
|
}
|
|
174
|
-
|
|
175
|
-
|
|
201
|
+
try {
|
|
202
|
+
const comments = await issueRepository.getIssueOrPullRequestComments(url);
|
|
203
|
+
return ok({ comments: serializeComments(comments) });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (isGitHubRateLimitError(error)) {
|
|
206
|
+
return rateLimited(error);
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
176
210
|
};
|
|
177
211
|
|
|
178
212
|
export const handlePrFiles = async (
|
|
@@ -182,11 +216,18 @@ export const handlePrFiles = async (
|
|
|
182
216
|
if (!url) {
|
|
183
217
|
return badRequest('url query parameter is required');
|
|
184
218
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
219
|
+
try {
|
|
220
|
+
const detail = await issueRepository.getPullRequestDetail(url);
|
|
221
|
+
if (detail === null) {
|
|
222
|
+
return ok({ files: null });
|
|
223
|
+
}
|
|
224
|
+
return ok({ files: detail.files });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (isGitHubRateLimitError(error)) {
|
|
227
|
+
return rateLimited(error);
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
188
230
|
}
|
|
189
|
-
return ok({ files: detail.files });
|
|
190
231
|
};
|
|
191
232
|
|
|
192
233
|
export const handlePrCommits = async (
|
|
@@ -196,8 +237,15 @@ export const handlePrCommits = async (
|
|
|
196
237
|
if (!url) {
|
|
197
238
|
return badRequest('url query parameter is required');
|
|
198
239
|
}
|
|
199
|
-
|
|
200
|
-
|
|
240
|
+
try {
|
|
241
|
+
const commits = await issueRepository.getPullRequestCommits(url);
|
|
242
|
+
return ok({ commits: serializeCommits(commits) });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if (isGitHubRateLimitError(error)) {
|
|
245
|
+
return rateLimited(error);
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
201
249
|
};
|
|
202
250
|
|
|
203
251
|
export const handleRelatedPrs = async (
|
|
@@ -207,30 +255,38 @@ export const handleRelatedPrs = async (
|
|
|
207
255
|
if (!url) {
|
|
208
256
|
return badRequest('url query parameter is required');
|
|
209
257
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
258
|
+
try {
|
|
259
|
+
const relatedPullRequests = await issueRepository.findRelatedOpenPRs(url);
|
|
260
|
+
const withSummaries: RelatedPullRequestWithSummary[] = await Promise.all(
|
|
261
|
+
relatedPullRequests.map(async (relatedPullRequest) => {
|
|
262
|
+
const summary = await issueRepository.getPullRequestSummary(
|
|
263
|
+
relatedPullRequest.url,
|
|
264
|
+
);
|
|
265
|
+
return {
|
|
266
|
+
url: relatedPullRequest.url,
|
|
267
|
+
branchName: relatedPullRequest.branchName,
|
|
268
|
+
createdAt: relatedPullRequest.createdAt.toISOString(),
|
|
269
|
+
isDraft: relatedPullRequest.isDraft,
|
|
270
|
+
isConflicted: relatedPullRequest.isConflicted,
|
|
271
|
+
mergeableStatus: deriveMergeableStatus(relatedPullRequest.mergeable),
|
|
272
|
+
isPassedAllCiJob: relatedPullRequest.isPassedAllCiJob,
|
|
273
|
+
isCiStateSuccess: relatedPullRequest.isCiStateSuccess,
|
|
274
|
+
isResolvedAllReviewComments:
|
|
275
|
+
relatedPullRequest.isResolvedAllReviewComments,
|
|
276
|
+
isBranchOutOfDate: relatedPullRequest.isBranchOutOfDate,
|
|
277
|
+
missingRequiredCheckNames:
|
|
278
|
+
relatedPullRequest.missingRequiredCheckNames,
|
|
279
|
+
summary,
|
|
280
|
+
};
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
return ok({ relatedPullRequests: withSummaries });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isGitHubRateLimitError(error)) {
|
|
286
|
+
return rateLimited(error);
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
234
290
|
};
|
|
235
291
|
|
|
236
292
|
export const handleIssueTitle = async (
|
|
@@ -245,10 +301,21 @@ export const handleIssueTitle = async (
|
|
|
245
301
|
if (cached !== null) {
|
|
246
302
|
return ok(cached);
|
|
247
303
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
304
|
+
try {
|
|
305
|
+
const state: IssueOrPullRequestState =
|
|
306
|
+
await issueRepository.getIssueOrPullRequestState(url);
|
|
307
|
+
cache.set(url, state);
|
|
308
|
+
return ok(state);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (isGitHubRateLimitError(error)) {
|
|
311
|
+
const stale = cache.getStale(url);
|
|
312
|
+
if (stale !== null) {
|
|
313
|
+
return ok(stale);
|
|
314
|
+
}
|
|
315
|
+
return rateLimited(error);
|
|
316
|
+
}
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
252
319
|
};
|
|
253
320
|
|
|
254
321
|
export const handlePullRequestStatus = async (
|
|
@@ -263,21 +330,32 @@ export const handlePullRequestStatus = async (
|
|
|
263
330
|
if (cached !== null) {
|
|
264
331
|
return ok(cached);
|
|
265
332
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
333
|
+
try {
|
|
334
|
+
const pullRequest = await issueRepository.getOpenPullRequestCiStatus(url);
|
|
335
|
+
const response: PullRequestStatusResponse =
|
|
336
|
+
pullRequest === null
|
|
337
|
+
? { found: false, status: null }
|
|
338
|
+
: {
|
|
339
|
+
found: true,
|
|
340
|
+
status: {
|
|
341
|
+
isConflicted: pullRequest.isConflicted,
|
|
342
|
+
mergeableStatus: deriveMergeableStatus(pullRequest.mergeable),
|
|
343
|
+
isPassedAllCiJob: pullRequest.isPassedAllCiJob,
|
|
344
|
+
isCiStateSuccess: pullRequest.isCiStateSuccess,
|
|
345
|
+
isBranchOutOfDate: pullRequest.isBranchOutOfDate,
|
|
346
|
+
missingRequiredCheckNames: pullRequest.missingRequiredCheckNames,
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
cache.set(url, response);
|
|
350
|
+
return ok(response);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (isGitHubRateLimitError(error)) {
|
|
353
|
+
const stale = cache.getStale(url);
|
|
354
|
+
if (stale !== null) {
|
|
355
|
+
return ok(stale);
|
|
356
|
+
}
|
|
357
|
+
return rateLimited(error);
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
283
361
|
};
|
|
@@ -284,6 +284,16 @@ describe('createConsoleApiClient', () => {
|
|
|
284
284
|
).rejects.toThrow('HTTP 500');
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
+
it('throws the error message from the JSON body of a non-ok response instead of the HTTP status code', async () => {
|
|
288
|
+
const rateLimitMessage =
|
|
289
|
+
'Failed to fetch body for https://github.com/o/r/issues/1: HTTP 403 GitHub rate limit exceeded, please retry shortly (resets at 2026-01-01T01:00:00.000Z)';
|
|
290
|
+
mockFetchOnce({ error: rateLimitMessage }, false);
|
|
291
|
+
const client = createConsoleApiClient();
|
|
292
|
+
await expect(
|
|
293
|
+
client.fetchItemBody('https://github.com/o/r/issues/1'),
|
|
294
|
+
).rejects.toThrow(rateLimitMessage);
|
|
295
|
+
});
|
|
296
|
+
|
|
287
297
|
it('returns cached comments when the network fetch rejects', async () => {
|
|
288
298
|
const cachedBody = {
|
|
289
299
|
comments: [
|
|
@@ -80,7 +80,20 @@ const requestJson = async (
|
|
|
80
80
|
try {
|
|
81
81
|
const response = await fetch(url);
|
|
82
82
|
if (!response.ok) {
|
|
83
|
-
|
|
83
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
84
|
+
try {
|
|
85
|
+
const payload: unknown = await response.json();
|
|
86
|
+
if (
|
|
87
|
+
isRecord(payload) &&
|
|
88
|
+
typeof payload.error === 'string' &&
|
|
89
|
+
payload.error.length > 0
|
|
90
|
+
) {
|
|
91
|
+
errorMessage = payload.error;
|
|
92
|
+
}
|
|
93
|
+
} catch (e: unknown) {
|
|
94
|
+
console.warn('Failed to parse error body from non-ok response:', e);
|
|
95
|
+
}
|
|
96
|
+
throw new Error(errorMessage);
|
|
84
97
|
}
|
|
85
98
|
const payload: unknown = await response.json();
|
|
86
99
|
try {
|