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.
Files changed (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/entry-points/cli/index.js +1 -1
  3. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  4. package/bin/adapter/entry-points/console/consoleReadApi.js +126 -48
  5. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/{index-ZYMkO0fm.js → index-BcnLfodS.js} +1 -1
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  8. package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js +12 -5
  9. package/bin/adapter/repositories/LocalCommandIssueAttachmentRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +19 -17
  11. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  12. package/bin/adapter/repositories/issue/githubRateLimitRetry.js +8 -1
  13. package/bin/adapter/repositories/issue/githubRateLimitRetry.js.map +1 -1
  14. package/package.json +1 -1
  15. package/src/adapter/entry-points/cli/index.ts +2 -0
  16. package/src/adapter/entry-points/console/consoleReadApi.test.ts +254 -0
  17. package/src/adapter/entry-points/console/consoleReadApi.ts +133 -55
  18. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +10 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +14 -1
  20. package/src/adapter/entry-points/console/ui-dist/assets/{index-ZYMkO0fm.js → index-BcnLfodS.js} +1 -1
  21. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  22. package/src/adapter/entry-points/console/webServer.test.ts +35 -0
  23. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.test.ts +71 -1
  24. package/src/adapter/repositories/LocalCommandIssueAttachmentRepository.ts +17 -4
  25. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +47 -19
  26. package/src/adapter/repositories/issue/githubRateLimitRetry.ts +4 -0
  27. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  28. package/types/adapter/entry-points/console/consoleReadApi.d.ts +2 -0
  29. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -1
  30. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts +2 -1
  31. package/types/adapter/repositories/LocalCommandIssueAttachmentRepository.d.ts.map +1 -1
  32. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -0
  33. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  34. package/types/adapter/repositories/issue/githubRateLimitRetry.d.ts +3 -0
  35. 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
- const body = await issueRepository.getIssueOrPullRequestBody(url);
164
- return ok({ body });
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
- const comments = await issueRepository.getIssueOrPullRequestComments(url);
175
- return ok({ comments: serializeComments(comments) });
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
- const detail = await issueRepository.getPullRequestDetail(url);
186
- if (detail === null) {
187
- return ok({ files: null });
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
- const commits = await issueRepository.getPullRequestCommits(url);
200
- return ok({ commits: serializeCommits(commits) });
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
- const relatedPullRequests = await issueRepository.findRelatedOpenPRs(url);
211
- const withSummaries: RelatedPullRequestWithSummary[] = await Promise.all(
212
- relatedPullRequests.map(async (relatedPullRequest) => {
213
- const summary = await issueRepository.getPullRequestSummary(
214
- relatedPullRequest.url,
215
- );
216
- return {
217
- url: relatedPullRequest.url,
218
- branchName: relatedPullRequest.branchName,
219
- createdAt: relatedPullRequest.createdAt.toISOString(),
220
- isDraft: relatedPullRequest.isDraft,
221
- isConflicted: relatedPullRequest.isConflicted,
222
- mergeableStatus: deriveMergeableStatus(relatedPullRequest.mergeable),
223
- isPassedAllCiJob: relatedPullRequest.isPassedAllCiJob,
224
- isCiStateSuccess: relatedPullRequest.isCiStateSuccess,
225
- isResolvedAllReviewComments:
226
- relatedPullRequest.isResolvedAllReviewComments,
227
- isBranchOutOfDate: relatedPullRequest.isBranchOutOfDate,
228
- missingRequiredCheckNames: relatedPullRequest.missingRequiredCheckNames,
229
- summary,
230
- };
231
- }),
232
- );
233
- return ok({ relatedPullRequests: withSummaries });
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
- const state: IssueOrPullRequestState =
249
- await issueRepository.getIssueOrPullRequestState(url);
250
- cache.set(url, state);
251
- return ok(state);
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
- const pullRequest = await issueRepository.getOpenPullRequestCiStatus(url);
267
- const response: PullRequestStatusResponse =
268
- pullRequest === null
269
- ? { found: false, status: null }
270
- : {
271
- found: true,
272
- status: {
273
- isConflicted: pullRequest.isConflicted,
274
- mergeableStatus: deriveMergeableStatus(pullRequest.mergeable),
275
- isPassedAllCiJob: pullRequest.isPassedAllCiJob,
276
- isCiStateSuccess: pullRequest.isCiStateSuccess,
277
- isBranchOutOfDate: pullRequest.isBranchOutOfDate,
278
- missingRequiredCheckNames: pullRequest.missingRequiredCheckNames,
279
- },
280
- };
281
- cache.set(url, response);
282
- return ok(response);
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
- throw new Error(`HTTP ${response.status}`);
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 {