@remogram/provider-github-api 0.1.0-beta.3 → 0.1.0-beta.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 (2) hide show
  1. package/index.js +242 -27
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -12,18 +12,31 @@ import {
12
12
  gitAheadBehind,
13
13
  refsInventory,
14
14
  crInventory,
15
- mergeBlockersFromFacts,
15
+ buildMergePlanBodyFromFacts,
16
16
  ERROR_CODES,
17
17
  forgeError,
18
18
  forgeIngestCapabilityFacts,
19
19
  checkPaginationCapabilityFacts,
20
+ openPullListCapabilityFacts,
20
21
  DEFAULT_CHECK_STATUS_PAGE_SIZE,
21
22
  MAX_CHECK_STATUS_PAGES,
23
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
22
24
  fetchWithIngestPageBackoff,
23
25
  paginateOffsetListPages,
24
26
  fetchPageWithIngestBackoff,
25
27
  withPerPageParam,
26
28
  apiProviderCommands,
29
+ normalizeCrInventorySort,
30
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
31
+ isCrInventoryFastPathEligible,
32
+ validateFastPathPageLength,
33
+ isNumberSortFastPathEligible,
34
+ isNumberSortFullCollectRequired,
35
+ resolvePaginatedEntryCount,
36
+ resolveListTruncatedWithTrustedTotal,
37
+ orderOpenPullNumbers,
38
+ buildOpenPullListMeta,
39
+ githubOpenPullSortQuery,
27
40
  } from '@remogram/core';
28
41
 
29
42
  const PUBLIC_GITHUB_HOST = 'github.com';
@@ -188,12 +201,36 @@ async function paginateGitHubLinkPages({
188
201
  token,
189
202
  initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
190
203
  mapPageItems,
204
+ seededFirstPage = null,
191
205
  }) {
192
206
  const all = [];
193
207
  let truncated = false;
208
+ let walkedCount = 0;
194
209
  let url = startUrl;
195
210
  let activeLimit = initialLimit;
196
- for (let page = 0; page < MAX_CHECK_PAGES && url; page += 1) {
211
+ let pageIndex = 0;
212
+
213
+ if (seededFirstPage) {
214
+ const { items, linkHeader, currentUrl, usedLimit } = seededFirstPage;
215
+ activeLimit = usedLimit;
216
+ const mapped = mapPageItems(items);
217
+ walkedCount += mapped.length;
218
+ all.push(...mapped);
219
+ const linkPage = resolveGitHubLinkNextPage({
220
+ trustedOrigin,
221
+ currentUrl,
222
+ linkHeader,
223
+ pageIndex: 0,
224
+ maxPages: MAX_CHECK_PAGES,
225
+ });
226
+ if (linkPage.truncated) {
227
+ truncated = true;
228
+ }
229
+ url = linkPage.nextUrl ? withPerPageParam(linkPage.nextUrl, activeLimit) : null;
230
+ pageIndex = 1;
231
+ }
232
+
233
+ for (let page = pageIndex; page < MAX_CHECK_PAGES && url; page += 1) {
197
234
  const currentUrl = url;
198
235
  let usedLimit = activeLimit;
199
236
  const { body, headers } = await fetchWithIngestPageBackoff(
@@ -208,7 +245,9 @@ async function paginateGitHubLinkPages({
208
245
  activeLimit,
209
246
  );
210
247
  activeLimit = usedLimit;
211
- all.push(...mapPageItems(body));
248
+ const mapped = mapPageItems(body);
249
+ walkedCount += mapped.length;
250
+ all.push(...mapped);
212
251
  const linkHeader = headers?.get?.('link') ?? headers?.get?.('Link') ?? null;
213
252
  const linkPage = resolveGitHubLinkNextPage({
214
253
  trustedOrigin,
@@ -222,7 +261,7 @@ async function paginateGitHubLinkPages({
222
261
  }
223
262
  url = linkPage.nextUrl ? withPerPageParam(linkPage.nextUrl, activeLimit) : null;
224
263
  }
225
- return { items: all, truncated };
264
+ return { items: all, truncated, walked_count: walkedCount };
226
265
  }
227
266
 
228
267
  export async function githubFetchPaginated(config, parsed, path, slice) {
@@ -347,6 +386,9 @@ export function providerCapabilities() {
347
386
  pageSizeParam: 'per_page',
348
387
  sourceCount: check_sources.length,
349
388
  }),
389
+ ...openPullListCapabilityFacts({
390
+ totalCountSource: 'search_api',
391
+ }),
350
392
  };
351
393
  }
352
394
 
@@ -488,48 +530,167 @@ export async function prChecks(ctx, opts) {
488
530
  export async function mergePlan(ctx, opts) {
489
531
  const view = await prView(ctx, opts);
490
532
  const checks = await prChecks(ctx, { number: view.pr_number });
491
- const blockers = mergeBlockersFromFacts(view, checks);
533
+ return buildMergePlanBodyFromFacts(ctx, view, checks, opts);
534
+ }
535
+
536
+ const GITHUB_OPEN_PULL_COMPLIANT_MAX =
537
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
538
+ const GITHUB_PAGE_SIZE = 100;
539
+
540
+ function githubOpenPullsListPath(config, sliceSort) {
541
+ const params = new URLSearchParams({ state: 'open', ...githubOpenPullSortQuery(sliceSort) });
542
+ return `${repoApiPath(config, 'pulls')}?${params.toString()}`;
543
+ }
544
+
545
+ async function fetchGitHubOpenPullSearchTotal(ctx) {
546
+ const base = apiBase(ctx.config, ctx.parsed);
547
+ const { token } = requireToken();
548
+ const owner = encodeURIComponent(ctx.config.owner);
549
+ const repo = encodeURIComponent(ctx.config.repo);
550
+ const searchPath = `/search/issues?q=repo:${owner}/${repo}+is:pr+state:open&per_page=1`;
551
+ try {
552
+ const { body } = await fetchJsonWithMeta(`${base}${searchPath}`, {
553
+ headers: authHeaders(token),
554
+ });
555
+ if (!body || body.incomplete_results === true) return null;
556
+ const total = Number(body.total_count);
557
+ const maxTrusted = GITHUB_OPEN_PULL_COMPLIANT_MAX * 2;
558
+ if (!Number.isInteger(total) || total <= 0 || total > maxTrusted) return null;
559
+ return total;
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
565
+ async function probeGitHubOpenPullPageOne(ctx, retainMax, sliceSort) {
566
+ const totalCount = await fetchGitHubOpenPullSearchTotal(ctx);
567
+ if (totalCount == null) return null;
568
+
569
+ const base = apiBase(ctx.config, ctx.parsed);
570
+ const { token } = requireToken();
571
+ const requestLimit = Math.min(retainMax, GITHUB_PAGE_SIZE);
572
+ const listPath = githubOpenPullsListPath(ctx.config, sliceSort);
573
+ const listUrl = withPerPageParam(`${base}${listPath}`, requestLimit);
574
+ try {
575
+ const { body, headers } = await fetchJsonWithMeta(listUrl, {
576
+ headers: authHeaders(token),
577
+ });
578
+ if (!Array.isArray(body)) return null;
579
+ const listTruncated = totalCount > GITHUB_OPEN_PULL_COMPLIANT_MAX;
580
+ const linkHeader = headers?.get?.('link') ?? headers?.get?.('Link') ?? null;
581
+ return { body, totalCount, listTruncated, requestLimit, listUrl, linkHeader };
582
+ } catch {
583
+ return null;
584
+ }
585
+ }
586
+
587
+ function buildGitHubOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, listTruncated) {
588
+ let numbers = orderOpenPullNumbers(body, (pr) => pr?.number, sliceSort);
589
+ if (numbers.length > retainMax) numbers = numbers.slice(0, retainMax);
590
+ return buildOpenPullListMeta({
591
+ totalCount,
592
+ numbers,
593
+ listTruncated,
594
+ sliceSort,
595
+ });
596
+ }
597
+
598
+ function githubProbePaginationOpts(probe) {
599
+ const { body, totalCount, requestLimit, listUrl, linkHeader } = probe;
492
600
  return {
493
- pr_number: view.pr_number,
494
- mergeability: view.mergeability,
495
- checks_conclusion: checks.check_conclusion,
496
- blockers,
601
+ trustedTotalCount: totalCount,
602
+ seededFirstPage: { items: body, usedLimit: requestLimit, listUrl, linkHeader },
497
603
  };
498
604
  }
499
605
 
500
- export async function listOpenPullsWithMeta(ctx, opts = {}) {
501
- apiBase(ctx.config, ctx.parsed);
502
- requireToken();
606
+ async function paginateGitHubOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
607
+ const {
608
+ trustedTotalCount = null,
609
+ seededFirstPage = null,
610
+ numberSortFullCollect = false,
611
+ } = paginationOpts;
503
612
  const base = apiBase(ctx.config, ctx.parsed);
504
613
  const { token } = requireToken();
505
614
  const listLimit =
506
615
  opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
507
616
  ? Number(opts.limit)
508
617
  : null;
509
- const GITHUB_PAGE_SIZE = 100;
510
618
  const all = [];
511
619
  let listTruncated = false;
620
+ let walkedCount = 0;
621
+ const listPath = githubOpenPullsListPath(ctx.config, sliceSort);
622
+ const retainMax =
623
+ listLimit == null &&
624
+ opts.retain_max != null &&
625
+ Number.isInteger(Number(opts.retain_max)) &&
626
+ Number(opts.retain_max) > 0
627
+ ? Number(opts.retain_max)
628
+ : null;
512
629
 
513
- if (listLimit == null) {
630
+ if (listLimit == null && numberSortFullCollect) {
631
+ const {
632
+ items: offsetItems,
633
+ list_truncated: offsetTruncated,
634
+ walked_count: offsetWalkedCount,
635
+ } = await paginateOffsetListPages({
636
+ pageSize: GITHUB_PAGE_SIZE,
637
+ retainMax: null,
638
+ trustedEntryCount: trustedTotalCount,
639
+ seededFirstPage: seededFirstPage
640
+ ? { items: seededFirstPage.items, usedLimit: seededFirstPage.usedLimit }
641
+ : null,
642
+ fetchPage: async ({ page, limit }) => {
643
+ const pageUrl = `${base}${listPath}&page=${page}`;
644
+ const { body } = await fetchJsonWithMeta(withPerPageParam(pageUrl, limit), {
645
+ headers: authHeaders(token),
646
+ });
647
+ return Array.isArray(body) ? body : [];
648
+ },
649
+ });
650
+ all.push(...offsetItems);
651
+ listTruncated = offsetTruncated;
652
+ walkedCount = offsetWalkedCount;
653
+ } else if (listLimit == null) {
514
654
  const trustedOrigin = new URL(base).origin;
515
- const startUrl = `${base}${repoApiPath(ctx.config, 'pulls')}?state=open`;
516
- const { items: linkItems, truncated: linkTruncated } = await paginateGitHubLinkPages({
655
+ const startUrl = `${base}${listPath}`;
656
+ const linkSeed = seededFirstPage
657
+ ? {
658
+ items: seededFirstPage.items,
659
+ linkHeader: seededFirstPage.linkHeader,
660
+ currentUrl: seededFirstPage.listUrl,
661
+ usedLimit: seededFirstPage.usedLimit,
662
+ }
663
+ : null;
664
+ const {
665
+ items: linkItems,
666
+ truncated: linkTruncated,
667
+ walked_count: linkWalkedCount,
668
+ } = await paginateGitHubLinkPages({
517
669
  trustedOrigin,
518
670
  startUrl,
519
671
  token,
672
+ initialLimit: seededFirstPage?.usedLimit ?? DEFAULT_CHECK_STATUS_PAGE_SIZE,
520
673
  mapPageItems: (body) => (Array.isArray(body) ? body : []),
674
+ seededFirstPage: linkSeed,
521
675
  });
522
676
  all.push(...linkItems);
523
677
  listTruncated = linkTruncated;
678
+ walkedCount = linkWalkedCount;
524
679
  } else {
525
- const { items: limitItems, list_truncated: limitTruncated } = await paginateOffsetListPages({
680
+ const {
681
+ items: limitItems,
682
+ list_truncated: limitTruncated,
683
+ walked_count: limitWalkedCount,
684
+ } = await paginateOffsetListPages({
526
685
  pageSize: GITHUB_PAGE_SIZE,
527
686
  listLimit,
528
- // GitHub/GitLab use fixed pageSize with optional listLimit; mark truncated at maxPages.
529
- // Gitea passes pageSize=min(listLimit, cap) so the limit branch often exits in one page.
530
687
  maxPagesTruncatesWithLimit: true,
688
+ trustedEntryCount: trustedTotalCount,
689
+ seededFirstPage: seededFirstPage
690
+ ? { items: seededFirstPage.items, usedLimit: seededFirstPage.usedLimit }
691
+ : null,
531
692
  fetchPage: async ({ page, limit }) => {
532
- const pageUrl = `${base}${repoApiPath(ctx.config, 'pulls')}?state=open&page=${page}`;
693
+ const pageUrl = `${base}${listPath}&page=${page}`;
533
694
  const { body } = await fetchJsonWithMeta(withPerPageParam(pageUrl, limit), {
534
695
  headers: authHeaders(token),
535
696
  });
@@ -538,16 +699,70 @@ export async function listOpenPullsWithMeta(ctx, opts = {}) {
538
699
  });
539
700
  all.push(...limitItems);
540
701
  listTruncated = limitTruncated;
702
+ walkedCount = limitWalkedCount;
703
+ }
704
+
705
+ let numbers = orderOpenPullNumbers(all, (pr) => pr?.number, sliceSort);
706
+ const outputCap = listLimit ?? retainMax;
707
+ if (outputCap != null && numbers.length > outputCap) {
708
+ numbers = numbers.slice(0, outputCap);
709
+ }
710
+ const entryCount =
711
+ trustedTotalCount != null
712
+ ? resolvePaginatedEntryCount(trustedTotalCount, walkedCount)
713
+ : undefined;
714
+ return {
715
+ numbers,
716
+ list_truncated: resolveListTruncatedWithTrustedTotal({
717
+ listTruncated,
718
+ trustedTotalCount,
719
+ walkedCount,
720
+ fullCollect: numberSortFullCollect,
721
+ }),
722
+ slice_sort: sliceSort,
723
+ ...(entryCount != null ? { entry_count: entryCount } : {}),
724
+ };
725
+ }
726
+
727
+ export async function listOpenPullsWithMeta(ctx, opts = {}) {
728
+ apiBase(ctx.config, ctx.parsed);
729
+ requireToken();
730
+ const sliceSort = normalizeCrInventorySort(opts.sort ?? DEFAULT_CR_INVENTORY_SLICE_SORT);
731
+ if (!isCrInventoryFastPathEligible(opts)) {
732
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort);
541
733
  }
542
734
 
543
- let numbers = all
544
- .map((pr) => pr.number)
545
- .filter((number) => Number.isInteger(number))
546
- .sort((a, b) => a - b);
547
- if (listLimit != null && numbers.length > listLimit) {
548
- numbers = numbers.slice(0, listLimit);
735
+ const retainMax = Number(opts.retain_max);
736
+ const probe = await probeGitHubOpenPullPageOne(ctx, retainMax, sliceSort);
737
+ if (!probe) {
738
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort);
549
739
  }
550
- return { numbers, list_truncated: listTruncated };
740
+
741
+ const { body, totalCount, listTruncated, requestLimit } = probe;
742
+
743
+ if (listTruncated) {
744
+ if (body.length === 0) {
745
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort, githubProbePaginationOpts(probe));
746
+ }
747
+ return buildGitHubOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, true);
748
+ }
749
+
750
+ if (
751
+ isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) &&
752
+ validateFastPathPageLength(totalCount, requestLimit, body.length)
753
+ ) {
754
+ return buildGitHubOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
755
+ }
756
+
757
+ return paginateGitHubOpenPullList(
758
+ ctx,
759
+ opts,
760
+ sliceSort,
761
+ {
762
+ ...githubProbePaginationOpts(probe),
763
+ numberSortFullCollect: isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort),
764
+ },
765
+ );
551
766
  }
552
767
 
553
768
  export async function listOpenPulls(ctx, opts = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/provider-github-api",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "GitHub API provider for remogram",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,6 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@remogram/core": "0.1.0-beta.3"
25
+ "@remogram/core": "0.1.0-beta.4"
26
26
  }
27
27
  }