@remogram/provider-github-api 0.1.0-beta.2 → 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 +381 -24
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  fetchJson,
3
3
  fetchJsonWithMeta,
4
4
  parseLinkHeader,
5
+ isTrustedPaginationUrl,
5
6
  sanitizeField,
6
7
  sanitizeUrl,
7
8
  assertGitRef,
@@ -9,10 +10,33 @@ import {
9
10
  gitRevParse,
10
11
  gitCurrentBranch,
11
12
  gitAheadBehind,
13
+ refsInventory,
14
+ crInventory,
15
+ buildMergePlanBodyFromFacts,
12
16
  ERROR_CODES,
13
17
  forgeError,
14
18
  forgeIngestCapabilityFacts,
19
+ checkPaginationCapabilityFacts,
20
+ openPullListCapabilityFacts,
21
+ DEFAULT_CHECK_STATUS_PAGE_SIZE,
22
+ MAX_CHECK_STATUS_PAGES,
23
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
24
+ fetchWithIngestPageBackoff,
25
+ paginateOffsetListPages,
26
+ fetchPageWithIngestBackoff,
27
+ withPerPageParam,
15
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,
16
40
  } from '@remogram/core';
17
41
 
18
42
  const PUBLIC_GITHUB_HOST = 'github.com';
@@ -40,6 +64,8 @@ query RemogramPrView($owner: String!, $repo: String!, $number: Int!) {
40
64
  const AUTH_CAPABILITIES = [
41
65
  'repo_status',
42
66
  'ref_compare',
67
+ 'ref_inventory',
68
+ 'cr_inventory',
43
69
  'pr_status',
44
70
  'pr_checks',
45
71
  'merge_plan',
@@ -153,22 +179,103 @@ export async function githubFetch(config, parsed, path, options = {}) {
153
179
  });
154
180
  }
155
181
 
156
- const MAX_CHECK_PAGES = 50;
182
+ const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
157
183
 
158
- export async function githubFetchPaginated(config, parsed, path, slice) {
159
- const base = apiBase(config, parsed);
160
- const { token } = requireToken();
184
+ export function resolveGitHubLinkNextPage({ trustedOrigin, currentUrl, linkHeader, pageIndex, maxPages }) {
185
+ const nextRaw = parseLinkHeader(linkHeader).next ?? null;
186
+ if (!nextRaw) {
187
+ return { nextUrl: null, truncated: false };
188
+ }
189
+ if (!isTrustedPaginationUrl(trustedOrigin, nextRaw, currentUrl)) {
190
+ return { nextUrl: null, truncated: true };
191
+ }
192
+ if (pageIndex === maxPages - 1) {
193
+ return { nextUrl: null, truncated: true };
194
+ }
195
+ return { nextUrl: new URL(nextRaw, currentUrl).href, truncated: false };
196
+ }
197
+
198
+ async function paginateGitHubLinkPages({
199
+ trustedOrigin,
200
+ startUrl,
201
+ token,
202
+ initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
203
+ mapPageItems,
204
+ seededFirstPage = null,
205
+ }) {
161
206
  const all = [];
162
- let url = `${base}${path}`;
163
- for (let page = 0; page < MAX_CHECK_PAGES && url; page += 1) {
164
- const { body, headers } = await fetchJsonWithMeta(url, {
165
- headers: authHeaders(token),
207
+ let truncated = false;
208
+ let walkedCount = 0;
209
+ let url = startUrl;
210
+ let activeLimit = initialLimit;
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,
166
225
  });
167
- all.push(...slice(body));
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) {
234
+ const currentUrl = url;
235
+ let usedLimit = activeLimit;
236
+ const { body, headers } = await fetchWithIngestPageBackoff(
237
+ (attemptUrl) =>
238
+ fetchJsonWithMeta(attemptUrl, {
239
+ headers: authHeaders(token),
240
+ }),
241
+ (limit) => {
242
+ usedLimit = limit;
243
+ return withPerPageParam(currentUrl, limit);
244
+ },
245
+ activeLimit,
246
+ );
247
+ activeLimit = usedLimit;
248
+ const mapped = mapPageItems(body);
249
+ walkedCount += mapped.length;
250
+ all.push(...mapped);
168
251
  const linkHeader = headers?.get?.('link') ?? headers?.get?.('Link') ?? null;
169
- url = parseLinkHeader(linkHeader).next ?? null;
252
+ const linkPage = resolveGitHubLinkNextPage({
253
+ trustedOrigin,
254
+ currentUrl,
255
+ linkHeader,
256
+ pageIndex: page,
257
+ maxPages: MAX_CHECK_PAGES,
258
+ });
259
+ if (linkPage.truncated) {
260
+ truncated = true;
261
+ }
262
+ url = linkPage.nextUrl ? withPerPageParam(linkPage.nextUrl, activeLimit) : null;
170
263
  }
171
- return all;
264
+ return { items: all, truncated, walked_count: walkedCount };
265
+ }
266
+
267
+ export async function githubFetchPaginated(config, parsed, path, slice) {
268
+ const base = apiBase(config, parsed);
269
+ const trustedOrigin = new URL(base).origin;
270
+ const { token } = requireToken();
271
+ const pageQuery = `${path.includes('?') ? '&' : '?'}per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
272
+ const startUrl = `${base}${path}${pageQuery}`;
273
+ return paginateGitHubLinkPages({
274
+ trustedOrigin,
275
+ startUrl,
276
+ token,
277
+ mapPageItems: (body) => slice(body),
278
+ });
172
279
  }
173
280
 
174
281
  export function graphqlEndpoint(config, parsed = {}) {
@@ -264,15 +371,24 @@ export async function repoStatus(ctx) {
264
371
  }
265
372
 
266
373
  export function providerCapabilities() {
374
+ const check_sources = ['commit_statuses', 'check_runs'];
267
375
  return {
268
376
  commands: STRUCTURED_COMMANDS,
269
377
  auth_envs: ['GITHUB_TOKEN', 'GH_TOKEN'],
270
- check_sources: ['commit_statuses', 'check_runs'],
378
+ check_sources,
271
379
  mergeability_confidence: 'derived',
272
380
  host_binding: 'verified_remote_host',
273
381
  pagination: 'supported',
274
382
  write_support: false,
275
383
  ...forgeIngestCapabilityFacts(),
384
+ ...checkPaginationCapabilityFacts({
385
+ strategy: 'link_header',
386
+ pageSizeParam: 'per_page',
387
+ sourceCount: check_sources.length,
388
+ }),
389
+ ...openPullListCapabilityFacts({
390
+ totalCountSource: 'search_api',
391
+ }),
276
392
  };
277
393
  }
278
394
 
@@ -383,12 +499,14 @@ export async function prChecks(ctx, opts) {
383
499
 
384
500
  const statusPath = repoApiPath(ctx.config, 'commits', sha, 'statuses');
385
501
  const checkRunsPath = repoApiPath(ctx.config, 'commits', sha, 'check-runs');
386
- const [statusRecords, checkRunRecords] = await Promise.all([
502
+ const [statusResult, checkRunResult] = await Promise.all([
387
503
  githubFetchPaginated(ctx.config, ctx.parsed, statusPath, (body) =>
388
504
  Array.isArray(body) ? body : [],
389
505
  ),
390
506
  githubFetchPaginated(ctx.config, ctx.parsed, checkRunsPath, (body) => body?.check_runs ?? []),
391
507
  ]);
508
+ const statusRecords = statusResult.items;
509
+ const checkRunRecords = checkRunResult.items;
392
510
  const mappedStatuses = statusRecords.map((s) => ({
393
511
  context: sanitizeField(s.context),
394
512
  state: normalizeCommitStatusState(s.state),
@@ -400,26 +518,262 @@ export async function prChecks(ctx, opts) {
400
518
  description: sanitizeField(checkRunDescription(run)),
401
519
  }));
402
520
  const mapped = [...mappedStatuses, ...mappedCheckRuns];
403
- return { head_sha: sha, check_conclusion: summarizeChecks(mapped), statuses: mapped };
521
+ const checks_truncated = statusResult.truncated || checkRunResult.truncated;
522
+ return {
523
+ head_sha: sha,
524
+ check_conclusion: summarizeChecks(mapped),
525
+ checks_truncated,
526
+ statuses: mapped,
527
+ };
404
528
  }
405
529
 
406
530
  export async function mergePlan(ctx, opts) {
407
531
  const view = await prView(ctx, opts);
408
532
  const checks = await prChecks(ctx, { number: view.pr_number });
409
- const blockers = [];
410
- if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
411
- if (view.state !== 'open') blockers.push('pr_not_open');
412
- if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
413
- if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
414
- if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
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;
415
600
  return {
416
- pr_number: view.pr_number,
417
- mergeability: view.mergeability,
418
- checks_conclusion: checks.check_conclusion,
419
- blockers,
601
+ trustedTotalCount: totalCount,
602
+ seededFirstPage: { items: body, usedLimit: requestLimit, listUrl, linkHeader },
603
+ };
604
+ }
605
+
606
+ async function paginateGitHubOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
607
+ const {
608
+ trustedTotalCount = null,
609
+ seededFirstPage = null,
610
+ numberSortFullCollect = false,
611
+ } = paginationOpts;
612
+ const base = apiBase(ctx.config, ctx.parsed);
613
+ const { token } = requireToken();
614
+ const listLimit =
615
+ opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
616
+ ? Number(opts.limit)
617
+ : null;
618
+ const all = [];
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;
629
+
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) {
654
+ const trustedOrigin = new URL(base).origin;
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({
669
+ trustedOrigin,
670
+ startUrl,
671
+ token,
672
+ initialLimit: seededFirstPage?.usedLimit ?? DEFAULT_CHECK_STATUS_PAGE_SIZE,
673
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
674
+ seededFirstPage: linkSeed,
675
+ });
676
+ all.push(...linkItems);
677
+ listTruncated = linkTruncated;
678
+ walkedCount = linkWalkedCount;
679
+ } else {
680
+ const {
681
+ items: limitItems,
682
+ list_truncated: limitTruncated,
683
+ walked_count: limitWalkedCount,
684
+ } = await paginateOffsetListPages({
685
+ pageSize: GITHUB_PAGE_SIZE,
686
+ listLimit,
687
+ maxPagesTruncatesWithLimit: true,
688
+ trustedEntryCount: trustedTotalCount,
689
+ seededFirstPage: seededFirstPage
690
+ ? { items: seededFirstPage.items, usedLimit: seededFirstPage.usedLimit }
691
+ : null,
692
+ fetchPage: async ({ page, limit }) => {
693
+ const pageUrl = `${base}${listPath}&page=${page}`;
694
+ const { body } = await fetchJsonWithMeta(withPerPageParam(pageUrl, limit), {
695
+ headers: authHeaders(token),
696
+ });
697
+ return Array.isArray(body) ? body : [];
698
+ },
699
+ });
700
+ all.push(...limitItems);
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 } : {}),
420
724
  };
421
725
  }
422
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);
733
+ }
734
+
735
+ const retainMax = Number(opts.retain_max);
736
+ const probe = await probeGitHubOpenPullPageOne(ctx, retainMax, sliceSort);
737
+ if (!probe) {
738
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort);
739
+ }
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
+ );
766
+ }
767
+
768
+ export async function listOpenPulls(ctx, opts = {}) {
769
+ const meta = await listOpenPullsWithMeta(ctx, opts);
770
+ return meta.numbers;
771
+ }
772
+
773
+ export async function crInventorySlice(ctx, opts = {}) {
774
+ return crInventory(ctx, { listOpenPulls, listOpenPullsWithMeta, prView, prChecks }, opts);
775
+ }
776
+
423
777
  export async function syncPlan(ctx, remoteName = 'origin') {
424
778
  assertGitRemote(remoteName, 'remote');
425
779
  apiBase(ctx.config, ctx.parsed);
@@ -453,6 +807,9 @@ export const provider = {
453
807
  providerCapabilities,
454
808
  repoStatus,
455
809
  refsCompare,
810
+ refsInventory,
811
+ listOpenPulls,
812
+ crInventory: crInventorySlice,
456
813
  prView,
457
814
  prChecks,
458
815
  mergePlan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/provider-github-api",
3
- "version": "0.1.0-beta.2",
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.2"
25
+ "@remogram/core": "0.1.0-beta.4"
26
26
  }
27
27
  }