github-issue-tower-defence-management 1.116.1 → 1.116.4

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 (39) hide show
  1. package/.github/workflows/commit-lint.yml +1 -1
  2. package/.github/workflows/configs/commitlint.config.cjs +32 -0
  3. package/.github/workflows/umino-project.yml +9 -3
  4. package/CHANGELOG.md +14 -0
  5. package/README.md +2 -2
  6. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js +18 -6
  7. package/bin/adapter/repositories/ProcClaudeLiveSessionRepository.js.map +1 -1
  8. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +101 -60
  9. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  10. package/bin/adapter/repositories/issue/githubRateLimitRetry.js +77 -0
  11. package/bin/adapter/repositories/issue/githubRateLimitRetry.js.map +1 -0
  12. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +6 -6
  13. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  14. package/bin/domain/usecases/OauthTokenSelectUseCase.js +1 -1
  15. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  16. package/package.json +1 -1
  17. package/renovate.json +1 -0
  18. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +9 -9
  19. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.test.ts +69 -7
  20. package/src/adapter/repositories/ProcClaudeLiveSessionRepository.ts +19 -8
  21. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +237 -1
  22. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +274 -209
  23. package/src/adapter/repositories/issue/githubRateLimitRetry.test.ts +167 -0
  24. package/src/adapter/repositories/issue/githubRateLimitRetry.ts +105 -0
  25. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +25 -3
  26. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +7 -7
  27. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +13 -4
  28. package/src/domain/usecases/OauthTokenSelectUseCase.ts +1 -1
  29. package/src/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.ts +1 -1
  30. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts +1 -0
  31. package/types/adapter/repositories/ProcClaudeLiveSessionRepository.d.ts.map +1 -1
  32. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +6 -2
  33. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  34. package/types/adapter/repositories/issue/githubRateLimitRetry.d.ts +10 -0
  35. package/types/adapter/repositories/issue/githubRateLimitRetry.d.ts.map +1 -0
  36. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +1 -1
  37. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
  38. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts +1 -1
  39. package/types/domain/usecases/adapter-interfaces/ClaudeLiveSessionRepository.d.ts.map +1 -1
@@ -22,6 +22,13 @@ import { BaseGitHubRepository } from '../BaseGitHubRepository';
22
22
  import { normalizeFieldName } from '../utils';
23
23
  import { LocalStorageRepository } from '../LocalStorageRepository';
24
24
  import { Member } from '../../../domain/entities/Member';
25
+ import {
26
+ Sleep,
27
+ realSleep,
28
+ fetchWithGitHubRateLimitRetry,
29
+ computeRateLimitResetIso,
30
+ hasRateLimitSignals,
31
+ } from './githubRateLimitRetry';
25
32
 
26
33
  type TimelineItem = {
27
34
  __typename: string;
@@ -471,10 +478,15 @@ export class ApiV3CheerioRestIssueRepository
471
478
  >,
472
479
  readonly localStorageRepository: LocalStorageRepository,
473
480
  readonly ghToken: string = process.env.GH_TOKEN || 'dummy',
481
+ readonly sleep: Sleep = realSleep,
474
482
  ) {
475
483
  super(localStorageRepository, ghToken);
476
484
  }
477
485
 
486
+ private fetchWithRateLimitRetry = (
487
+ request: () => Promise<Response>,
488
+ ): Promise<Response> => fetchWithGitHubRateLimitRetry(request, this.sleep);
489
+
478
490
  updateStatus: (
479
491
  project: Project,
480
492
  issue: Issue,
@@ -1106,17 +1118,19 @@ export class ApiV3CheerioRestIssueRepository
1106
1118
  let hasNextPage = true;
1107
1119
 
1108
1120
  while (hasNextPage) {
1109
- const response = await fetch('https://api.github.com/graphql', {
1110
- method: 'POST',
1111
- headers: {
1112
- Authorization: `Bearer ${this.ghToken}`,
1113
- 'Content-Type': 'application/json',
1114
- },
1115
- body: JSON.stringify({
1116
- query,
1117
- variables: { owner, repo, issueNumber, after },
1121
+ const response = await this.fetchWithRateLimitRetry(() =>
1122
+ fetch('https://api.github.com/graphql', {
1123
+ method: 'POST',
1124
+ headers: {
1125
+ Authorization: `Bearer ${this.ghToken}`,
1126
+ 'Content-Type': 'application/json',
1127
+ },
1128
+ body: JSON.stringify({
1129
+ query,
1130
+ variables: { owner, repo, issueNumber, after },
1131
+ }),
1118
1132
  }),
1119
- });
1133
+ );
1120
1134
 
1121
1135
  if (!response.ok) {
1122
1136
  throw new Error(
@@ -1285,17 +1299,19 @@ export class ApiV3CheerioRestIssueRepository
1285
1299
  }
1286
1300
  `;
1287
1301
 
1288
- const response = await fetch('https://api.github.com/graphql', {
1289
- method: 'POST',
1290
- headers: {
1291
- Authorization: `Bearer ${this.ghToken}`,
1292
- 'Content-Type': 'application/json',
1293
- },
1294
- body: JSON.stringify({
1295
- query,
1296
- variables: { owner, repo, prNumber },
1302
+ const response = await this.fetchWithRateLimitRetry(() =>
1303
+ fetch('https://api.github.com/graphql', {
1304
+ method: 'POST',
1305
+ headers: {
1306
+ Authorization: `Bearer ${this.ghToken}`,
1307
+ 'Content-Type': 'application/json',
1308
+ },
1309
+ body: JSON.stringify({
1310
+ query,
1311
+ variables: { owner, repo, prNumber },
1312
+ }),
1297
1313
  }),
1298
- });
1314
+ );
1299
1315
 
1300
1316
  if (!response.ok) {
1301
1317
  throw new Error(
@@ -1322,19 +1338,22 @@ export class ApiV3CheerioRestIssueRepository
1322
1338
 
1323
1339
  closePullRequest = async (prUrl: string): Promise<void> => {
1324
1340
  const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
1325
- const response = await fetch(
1326
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
1327
- {
1328
- method: 'PATCH',
1329
- headers: {
1330
- Authorization: `Bearer ${this.ghToken}`,
1331
- 'Content-Type': 'application/json',
1341
+ const response = await this.fetchWithRateLimitRetry(() =>
1342
+ fetch(
1343
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}`,
1344
+ {
1345
+ method: 'PATCH',
1346
+ headers: {
1347
+ Authorization: `Bearer ${this.ghToken}`,
1348
+ 'Content-Type': 'application/json',
1349
+ },
1350
+ body: JSON.stringify({ state: 'closed' }),
1332
1351
  },
1333
- body: JSON.stringify({ state: 'closed' }),
1334
- },
1352
+ ),
1335
1353
  );
1336
1354
  if (!response.ok) {
1337
- throw new Error(`Failed to close PR ${prUrl}: HTTP ${response.status}`);
1355
+ const reason = await this.formatGitHubErrorWithStatus(response);
1356
+ throw new Error(`Failed to close PR ${prUrl}: ${reason}`);
1338
1357
  }
1339
1358
  };
1340
1359
 
@@ -1345,21 +1364,22 @@ export class ApiV3CheerioRestIssueRepository
1345
1364
  const { owner, repo, issueNumber } = this.parseIssueUrl(issueUrl);
1346
1365
  const ownerSegment = encodeURIComponent(owner);
1347
1366
  const repoSegment = encodeURIComponent(repo);
1348
- const response = await fetch(
1349
- `https://api.github.com/repos/${ownerSegment}/${repoSegment}/issues/${issueNumber}`,
1350
- {
1351
- method: 'PATCH',
1352
- headers: {
1353
- Authorization: `Bearer ${this.ghToken}`,
1354
- 'Content-Type': 'application/json',
1367
+ const response = await this.fetchWithRateLimitRetry(() =>
1368
+ fetch(
1369
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/issues/${issueNumber}`,
1370
+ {
1371
+ method: 'PATCH',
1372
+ headers: {
1373
+ Authorization: `Bearer ${this.ghToken}`,
1374
+ 'Content-Type': 'application/json',
1375
+ },
1376
+ body: JSON.stringify({ state: 'closed', state_reason: stateReason }),
1355
1377
  },
1356
- body: JSON.stringify({ state: 'closed', state_reason: stateReason }),
1357
- },
1378
+ ),
1358
1379
  );
1359
1380
  if (!response.ok) {
1360
- throw new Error(
1361
- `Failed to close issue ${issueUrl}: HTTP ${response.status}`,
1362
- );
1381
+ const reason = await this.formatGitHubErrorWithStatus(response);
1382
+ throw new Error(`Failed to close issue ${issueUrl}: ${reason}`);
1363
1383
  }
1364
1384
  };
1365
1385
 
@@ -1370,19 +1390,22 @@ export class ApiV3CheerioRestIssueRepository
1370
1390
  let page = 1;
1371
1391
  let hasMore = true;
1372
1392
  while (hasMore) {
1373
- const response = await fetch(
1374
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=${perPage}&page=${page}`,
1375
- {
1376
- method: 'GET',
1377
- headers: {
1378
- Authorization: `Bearer ${this.ghToken}`,
1379
- Accept: 'application/vnd.github+json',
1393
+ const response = await this.fetchWithRateLimitRetry(() =>
1394
+ fetch(
1395
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/files?per_page=${perPage}&page=${page}`,
1396
+ {
1397
+ method: 'GET',
1398
+ headers: {
1399
+ Authorization: `Bearer ${this.ghToken}`,
1400
+ Accept: 'application/vnd.github+json',
1401
+ },
1380
1402
  },
1381
- },
1403
+ ),
1382
1404
  );
1383
1405
  if (!response.ok) {
1406
+ const reason = await this.formatGitHubErrorWithStatus(response);
1384
1407
  throw new Error(
1385
- `Failed to fetch changed files for PR ${prUrl}: HTTP ${response.status}`,
1408
+ `Failed to fetch changed files for PR ${prUrl}: ${reason}`,
1386
1409
  );
1387
1410
  }
1388
1411
  const body: unknown = await response.json();
@@ -1405,20 +1428,23 @@ export class ApiV3CheerioRestIssueRepository
1405
1428
 
1406
1429
  approvePullRequest = async (prUrl: string): Promise<void> => {
1407
1430
  const { owner, repo, issueNumber: prNumber } = this.parseIssueUrl(prUrl);
1408
- const response = await fetch(
1409
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
1410
- {
1411
- method: 'POST',
1412
- headers: {
1413
- Authorization: `Bearer ${this.ghToken}`,
1414
- 'Content-Type': 'application/json',
1415
- Accept: 'application/vnd.github+json',
1431
+ const response = await this.fetchWithRateLimitRetry(() =>
1432
+ fetch(
1433
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/reviews`,
1434
+ {
1435
+ method: 'POST',
1436
+ headers: {
1437
+ Authorization: `Bearer ${this.ghToken}`,
1438
+ 'Content-Type': 'application/json',
1439
+ Accept: 'application/vnd.github+json',
1440
+ },
1441
+ body: JSON.stringify({ event: 'APPROVE' }),
1416
1442
  },
1417
- body: JSON.stringify({ event: 'APPROVE' }),
1418
- },
1443
+ ),
1419
1444
  );
1420
1445
  if (!response.ok) {
1421
- throw new Error(`Failed to approve PR ${prUrl}: HTTP ${response.status}`);
1446
+ const reason = await this.formatGitHubErrorWithStatus(response);
1447
+ throw new Error(`Failed to approve PR ${prUrl}: ${reason}`);
1422
1448
  }
1423
1449
  };
1424
1450
 
@@ -1442,22 +1468,23 @@ export class ApiV3CheerioRestIssueRepository
1442
1468
  },
1443
1469
  ],
1444
1470
  };
1445
- const response = await fetch(
1446
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
1447
- {
1448
- method: 'POST',
1449
- headers: {
1450
- Authorization: `Bearer ${this.ghToken}`,
1451
- 'Content-Type': 'application/json',
1452
- Accept: 'application/vnd.github+json',
1471
+ const response = await this.fetchWithRateLimitRetry(() =>
1472
+ fetch(
1473
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/reviews`,
1474
+ {
1475
+ method: 'POST',
1476
+ headers: {
1477
+ Authorization: `Bearer ${this.ghToken}`,
1478
+ 'Content-Type': 'application/json',
1479
+ Accept: 'application/vnd.github+json',
1480
+ },
1481
+ body: JSON.stringify(reviewBody),
1453
1482
  },
1454
- body: JSON.stringify(reviewBody),
1455
- },
1483
+ ),
1456
1484
  );
1457
1485
  if (!response.ok) {
1458
- throw new Error(
1459
- `Failed to request changes on PR ${prUrl}: HTTP ${response.status}`,
1460
- );
1486
+ const reason = await this.formatGitHubErrorWithStatus(response);
1487
+ throw new Error(`Failed to request changes on PR ${prUrl}: ${reason}`);
1461
1488
  }
1462
1489
  };
1463
1490
 
@@ -1469,20 +1496,21 @@ export class ApiV3CheerioRestIssueRepository
1469
1496
  ): Promise<string> => {
1470
1497
  const ownerSegment = encodeURIComponent(owner);
1471
1498
  const repoSegment = encodeURIComponent(repo);
1472
- const response = await fetch(
1473
- `https://api.github.com/repos/${ownerSegment}/${repoSegment}/pulls/${prNumber}`,
1474
- {
1475
- method: 'GET',
1476
- headers: {
1477
- Authorization: `Bearer ${this.ghToken}`,
1478
- Accept: 'application/vnd.github+json',
1499
+ const response = await this.fetchWithRateLimitRetry(() =>
1500
+ fetch(
1501
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/pulls/${prNumber}`,
1502
+ {
1503
+ method: 'GET',
1504
+ headers: {
1505
+ Authorization: `Bearer ${this.ghToken}`,
1506
+ Accept: 'application/vnd.github+json',
1507
+ },
1479
1508
  },
1480
- },
1509
+ ),
1481
1510
  );
1482
1511
  if (!response.ok) {
1483
- throw new Error(
1484
- `Failed to fetch head commit for PR ${prUrl}: HTTP ${response.status}`,
1485
- );
1512
+ const reason = await this.formatGitHubErrorWithStatus(response);
1513
+ throw new Error(`Failed to fetch head commit for PR ${prUrl}: ${reason}`);
1486
1514
  }
1487
1515
  const body: unknown = await response.json();
1488
1516
  if (
@@ -1513,52 +1541,57 @@ export class ApiV3CheerioRestIssueRepository
1513
1541
  );
1514
1542
  const ownerSegment = encodeURIComponent(owner);
1515
1543
  const repoSegment = encodeURIComponent(repo);
1516
- const response = await fetch(
1517
- `https://api.github.com/repos/${ownerSegment}/${repoSegment}/pulls/${prNumber}/comments`,
1518
- {
1519
- method: 'POST',
1520
- headers: {
1521
- Authorization: `Bearer ${this.ghToken}`,
1522
- 'Content-Type': 'application/json',
1523
- Accept: 'application/vnd.github+json',
1544
+ const response = await this.fetchWithRateLimitRetry(() =>
1545
+ fetch(
1546
+ `https://api.github.com/repos/${ownerSegment}/${repoSegment}/pulls/${prNumber}/comments`,
1547
+ {
1548
+ method: 'POST',
1549
+ headers: {
1550
+ Authorization: `Bearer ${this.ghToken}`,
1551
+ 'Content-Type': 'application/json',
1552
+ Accept: 'application/vnd.github+json',
1553
+ },
1554
+ body: JSON.stringify({
1555
+ body: commentBody,
1556
+ commit_id: commitId,
1557
+ path,
1558
+ line,
1559
+ side,
1560
+ }),
1524
1561
  },
1525
- body: JSON.stringify({
1526
- body: commentBody,
1527
- commit_id: commitId,
1528
- path,
1529
- line,
1530
- side,
1531
- }),
1532
- },
1562
+ ),
1533
1563
  );
1534
1564
  if (!response.ok) {
1535
- const reason = await this.readGitHubErrorMessage(response);
1565
+ const reason = await this.formatGitHubErrorWithStatus(response);
1536
1566
  throw new Error(
1537
1567
  `Failed to create review comment on PR ${prUrl}: ${reason}`,
1538
1568
  );
1539
1569
  }
1540
1570
  };
1541
1571
 
1542
- private readGitHubErrorMessage = async (
1572
+ private readGitHubErrorReason = async (
1543
1573
  response: Response,
1544
- ): Promise<string> => {
1545
- const fallback = `HTTP ${response.status}`;
1574
+ ): Promise<string | null> => {
1546
1575
  let parsed: unknown;
1547
1576
  try {
1548
1577
  parsed = await response.json();
1549
1578
  } catch {
1550
- return fallback;
1579
+ return null;
1551
1580
  }
1552
1581
  if (!isRecord(parsed) || typeof parsed.message !== 'string') {
1553
- return fallback;
1582
+ return null;
1554
1583
  }
1555
1584
  if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
1556
1585
  const details = parsed.errors
1557
- .map((error) =>
1558
- isRecord(error) && typeof error.message === 'string'
1559
- ? error.message
1560
- : '',
1561
- )
1586
+ .map((error) => {
1587
+ if (typeof error === 'string') {
1588
+ return error;
1589
+ }
1590
+ if (isRecord(error) && typeof error.message === 'string') {
1591
+ return error.message;
1592
+ }
1593
+ return '';
1594
+ })
1562
1595
  .filter((detail) => detail.length > 0)
1563
1596
  .join('; ');
1564
1597
  if (details.length > 0) {
@@ -1568,23 +1601,47 @@ export class ApiV3CheerioRestIssueRepository
1568
1601
  return parsed.message;
1569
1602
  };
1570
1603
 
1604
+ private formatGitHubErrorWithStatus = async (
1605
+ response: Response,
1606
+ ): Promise<string> => {
1607
+ const status = `HTTP ${response.status}`;
1608
+ const bodyText = await response.clone().text();
1609
+ const reason = await this.readGitHubErrorReason(response);
1610
+ if (hasRateLimitSignals(response.status, response.headers, bodyText)) {
1611
+ const resetIso = computeRateLimitResetIso(response.headers);
1612
+ const resetSuffix = resetIso === null ? '' : ` (resets at ${resetIso})`;
1613
+ return `${status} GitHub rate limit exceeded, please retry shortly${resetSuffix}`;
1614
+ }
1615
+ if (response.status === 403) {
1616
+ const permissionSuffix = reason === null ? '' : ` ${reason}`;
1617
+ return `${status} permission denied, the token cannot perform this operation${permissionSuffix}`;
1618
+ }
1619
+ if (reason === null) {
1620
+ return status;
1621
+ }
1622
+ return `${status} ${reason}`;
1623
+ };
1624
+
1571
1625
  deletePullRequestBranch = async (
1572
1626
  prUrl: string,
1573
1627
  branchName: string,
1574
1628
  ): Promise<void> => {
1575
1629
  const { owner, repo } = this.parseIssueUrl(prUrl);
1576
- const response = await fetch(
1577
- `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${encodeURIComponent(branchName)}`,
1578
- {
1579
- method: 'DELETE',
1580
- headers: {
1581
- Authorization: `Bearer ${this.ghToken}`,
1630
+ const response = await this.fetchWithRateLimitRetry(() =>
1631
+ fetch(
1632
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/refs/heads/${encodeURIComponent(branchName)}`,
1633
+ {
1634
+ method: 'DELETE',
1635
+ headers: {
1636
+ Authorization: `Bearer ${this.ghToken}`,
1637
+ },
1582
1638
  },
1583
- },
1639
+ ),
1584
1640
  );
1585
1641
  if (!response.ok && response.status !== 422) {
1642
+ const reason = await this.formatGitHubErrorWithStatus(response);
1586
1643
  throw new Error(
1587
- `Failed to delete branch ${branchName} for PR ${prUrl}: HTTP ${response.status}`,
1644
+ `Failed to delete branch ${branchName} for PR ${prUrl}: ${reason}`,
1588
1645
  );
1589
1646
  }
1590
1647
  };
@@ -1598,20 +1655,21 @@ export class ApiV3CheerioRestIssueRepository
1598
1655
 
1599
1656
  getIssueOrPullRequestBody = async (url: string): Promise<string> => {
1600
1657
  const { owner, repo, issueNumber } = this.parseIssueUrl(url);
1601
- const response = await fetch(
1602
- `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`,
1603
- {
1604
- method: 'GET',
1605
- headers: {
1606
- Authorization: `Bearer ${this.ghToken}`,
1607
- Accept: 'application/vnd.github+json',
1658
+ const response = await this.fetchWithRateLimitRetry(() =>
1659
+ fetch(
1660
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`,
1661
+ {
1662
+ method: 'GET',
1663
+ headers: {
1664
+ Authorization: `Bearer ${this.ghToken}`,
1665
+ Accept: 'application/vnd.github+json',
1666
+ },
1608
1667
  },
1609
- },
1668
+ ),
1610
1669
  );
1611
1670
  if (!response.ok) {
1612
- throw new Error(
1613
- `Failed to fetch body for ${url}: HTTP ${response.status}`,
1614
- );
1671
+ const reason = await this.formatGitHubErrorWithStatus(response);
1672
+ throw new Error(`Failed to fetch body for ${url}: ${reason}`);
1615
1673
  }
1616
1674
  const body: unknown = await response.json();
1617
1675
  if (!isIssueOrPullRequestBodyResponse(body)) {
@@ -1631,20 +1689,21 @@ export class ApiV3CheerioRestIssueRepository
1631
1689
  let page = 1;
1632
1690
  let hasMore = true;
1633
1691
  while (hasMore) {
1634
- const response = await fetch(
1635
- `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`,
1636
- {
1637
- method: 'GET',
1638
- headers: {
1639
- Authorization: `Bearer ${this.ghToken}`,
1640
- Accept: 'application/vnd.github+json',
1692
+ const response = await this.fetchWithRateLimitRetry(() =>
1693
+ fetch(
1694
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`,
1695
+ {
1696
+ method: 'GET',
1697
+ headers: {
1698
+ Authorization: `Bearer ${this.ghToken}`,
1699
+ Accept: 'application/vnd.github+json',
1700
+ },
1641
1701
  },
1642
- },
1702
+ ),
1643
1703
  );
1644
1704
  if (!response.ok) {
1645
- throw new Error(
1646
- `Failed to fetch comments for ${url}: HTTP ${response.status}`,
1647
- );
1705
+ const reason = await this.formatGitHubErrorWithStatus(response);
1706
+ throw new Error(`Failed to fetch comments for ${url}: ${reason}`);
1648
1707
  }
1649
1708
  const body: unknown = await response.json();
1650
1709
  if (!isIssueCommentsResponse(body)) {
@@ -1680,20 +1739,21 @@ export class ApiV3CheerioRestIssueRepository
1680
1739
  if (!isPr) {
1681
1740
  return null;
1682
1741
  }
1683
- const detailResponse = await fetch(
1684
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
1685
- {
1686
- method: 'GET',
1687
- headers: {
1688
- Authorization: `Bearer ${this.ghToken}`,
1689
- Accept: 'application/vnd.github+json',
1742
+ const detailResponse = await this.fetchWithRateLimitRetry(() =>
1743
+ fetch(
1744
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}`,
1745
+ {
1746
+ method: 'GET',
1747
+ headers: {
1748
+ Authorization: `Bearer ${this.ghToken}`,
1749
+ Accept: 'application/vnd.github+json',
1750
+ },
1690
1751
  },
1691
- },
1752
+ ),
1692
1753
  );
1693
1754
  if (!detailResponse.ok) {
1694
- throw new Error(
1695
- `Failed to fetch detail for PR ${prUrl}: HTTP ${detailResponse.status}`,
1696
- );
1755
+ const reason = await this.formatGitHubErrorWithStatus(detailResponse);
1756
+ throw new Error(`Failed to fetch detail for PR ${prUrl}: ${reason}`);
1697
1757
  }
1698
1758
  const detailBody: unknown = await detailResponse.json();
1699
1759
  if (!isPullRequestDetailResponse(detailBody)) {
@@ -1733,20 +1793,21 @@ export class ApiV3CheerioRestIssueRepository
1733
1793
  let page = 1;
1734
1794
  let hasMore = true;
1735
1795
  while (hasMore) {
1736
- const response = await fetch(
1737
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files?per_page=${perPage}&page=${page}`,
1738
- {
1739
- method: 'GET',
1740
- headers: {
1741
- Authorization: `Bearer ${this.ghToken}`,
1742
- Accept: 'application/vnd.github+json',
1796
+ const response = await this.fetchWithRateLimitRetry(() =>
1797
+ fetch(
1798
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/files?per_page=${perPage}&page=${page}`,
1799
+ {
1800
+ method: 'GET',
1801
+ headers: {
1802
+ Authorization: `Bearer ${this.ghToken}`,
1803
+ Accept: 'application/vnd.github+json',
1804
+ },
1743
1805
  },
1744
- },
1806
+ ),
1745
1807
  );
1746
1808
  if (!response.ok) {
1747
- throw new Error(
1748
- `Failed to fetch files for PR ${prUrl}: HTTP ${response.status}`,
1749
- );
1809
+ const reason = await this.formatGitHubErrorWithStatus(response);
1810
+ throw new Error(`Failed to fetch files for PR ${prUrl}: ${reason}`);
1750
1811
  }
1751
1812
  const body: unknown = await response.json();
1752
1813
  if (!isPullRequestDetailFilesResponse(body)) {
@@ -1789,20 +1850,21 @@ export class ApiV3CheerioRestIssueRepository
1789
1850
  let page = 1;
1790
1851
  let hasMore = true;
1791
1852
  while (hasMore) {
1792
- const response = await fetch(
1793
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/commits?per_page=${perPage}&page=${page}`,
1794
- {
1795
- method: 'GET',
1796
- headers: {
1797
- Authorization: `Bearer ${this.ghToken}`,
1798
- Accept: 'application/vnd.github+json',
1853
+ const response = await this.fetchWithRateLimitRetry(() =>
1854
+ fetch(
1855
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}/commits?per_page=${perPage}&page=${page}`,
1856
+ {
1857
+ method: 'GET',
1858
+ headers: {
1859
+ Authorization: `Bearer ${this.ghToken}`,
1860
+ Accept: 'application/vnd.github+json',
1861
+ },
1799
1862
  },
1800
- },
1863
+ ),
1801
1864
  );
1802
1865
  if (!response.ok) {
1803
- throw new Error(
1804
- `Failed to fetch commits for PR ${prUrl}: HTTP ${response.status}`,
1805
- );
1866
+ const reason = await this.formatGitHubErrorWithStatus(response);
1867
+ throw new Error(`Failed to fetch commits for PR ${prUrl}: ${reason}`);
1806
1868
  }
1807
1869
  const body: unknown = await response.json();
1808
1870
  if (!isPullRequestCommitsResponse(body)) {
@@ -1832,20 +1894,21 @@ export class ApiV3CheerioRestIssueRepository
1832
1894
  ): Promise<{ state: string; merged: boolean; isPullRequest: boolean }> => {
1833
1895
  const { owner, repo, issueNumber, isPr } = this.parseIssueUrl(url);
1834
1896
  if (isPr) {
1835
- const response = await fetch(
1836
- `https://api.github.com/repos/${owner}/${repo}/pulls/${issueNumber}`,
1837
- {
1838
- method: 'GET',
1839
- headers: {
1840
- Authorization: `Bearer ${this.ghToken}`,
1841
- Accept: 'application/vnd.github+json',
1897
+ const response = await this.fetchWithRateLimitRetry(() =>
1898
+ fetch(
1899
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${issueNumber}`,
1900
+ {
1901
+ method: 'GET',
1902
+ headers: {
1903
+ Authorization: `Bearer ${this.ghToken}`,
1904
+ Accept: 'application/vnd.github+json',
1905
+ },
1842
1906
  },
1843
- },
1907
+ ),
1844
1908
  );
1845
1909
  if (!response.ok) {
1846
- throw new Error(
1847
- `Failed to fetch state for ${url}: HTTP ${response.status}`,
1848
- );
1910
+ const reason = await this.formatGitHubErrorWithStatus(response);
1911
+ throw new Error(`Failed to fetch state for ${url}: ${reason}`);
1849
1912
  }
1850
1913
  const body: unknown = await response.json();
1851
1914
  if (!isPullRequestDetailResponse(body)) {
@@ -1855,20 +1918,21 @@ export class ApiV3CheerioRestIssueRepository
1855
1918
  }
1856
1919
  return { state: body.state, merged: body.merged, isPullRequest: true };
1857
1920
  }
1858
- const response = await fetch(
1859
- `https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`,
1860
- {
1861
- method: 'GET',
1862
- headers: {
1863
- Authorization: `Bearer ${this.ghToken}`,
1864
- Accept: 'application/vnd.github+json',
1921
+ const response = await this.fetchWithRateLimitRetry(() =>
1922
+ fetch(
1923
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`,
1924
+ {
1925
+ method: 'GET',
1926
+ headers: {
1927
+ Authorization: `Bearer ${this.ghToken}`,
1928
+ Accept: 'application/vnd.github+json',
1929
+ },
1865
1930
  },
1866
- },
1931
+ ),
1867
1932
  );
1868
1933
  if (!response.ok) {
1869
- throw new Error(
1870
- `Failed to fetch state for ${url}: HTTP ${response.status}`,
1871
- );
1934
+ const reason = await this.formatGitHubErrorWithStatus(response);
1935
+ throw new Error(`Failed to fetch state for ${url}: ${reason}`);
1872
1936
  }
1873
1937
  const body: unknown = await response.json();
1874
1938
  if (!isIssueOrPullRequestStateResponse(body)) {
@@ -1897,20 +1961,21 @@ export class ApiV3CheerioRestIssueRepository
1897
1961
  if (!isPr) {
1898
1962
  return null;
1899
1963
  }
1900
- const response = await fetch(
1901
- `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
1902
- {
1903
- method: 'GET',
1904
- headers: {
1905
- Authorization: `Bearer ${this.ghToken}`,
1906
- Accept: 'application/vnd.github+json',
1964
+ const response = await this.fetchWithRateLimitRetry(() =>
1965
+ fetch(
1966
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${prNumber}`,
1967
+ {
1968
+ method: 'GET',
1969
+ headers: {
1970
+ Authorization: `Bearer ${this.ghToken}`,
1971
+ Accept: 'application/vnd.github+json',
1972
+ },
1907
1973
  },
1908
- },
1974
+ ),
1909
1975
  );
1910
1976
  if (!response.ok) {
1911
- throw new Error(
1912
- `Failed to fetch summary for PR ${prUrl}: HTTP ${response.status}`,
1913
- );
1977
+ const reason = await this.formatGitHubErrorWithStatus(response);
1978
+ throw new Error(`Failed to fetch summary for PR ${prUrl}: ${reason}`);
1914
1979
  }
1915
1980
  const body: unknown = await response.json();
1916
1981
  if (!isPullRequestDetailResponse(body)) {