@remogram/provider-github-api 0.1.0-beta.1 → 0.1.0-beta.3

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 +164 -21
  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,9 +10,20 @@ import {
9
10
  gitRevParse,
10
11
  gitCurrentBranch,
11
12
  gitAheadBehind,
13
+ refsInventory,
14
+ crInventory,
15
+ mergeBlockersFromFacts,
12
16
  ERROR_CODES,
13
17
  forgeError,
14
18
  forgeIngestCapabilityFacts,
19
+ checkPaginationCapabilityFacts,
20
+ DEFAULT_CHECK_STATUS_PAGE_SIZE,
21
+ MAX_CHECK_STATUS_PAGES,
22
+ fetchWithIngestPageBackoff,
23
+ paginateOffsetListPages,
24
+ fetchPageWithIngestBackoff,
25
+ withPerPageParam,
26
+ apiProviderCommands,
15
27
  } from '@remogram/core';
16
28
 
17
29
  const PUBLIC_GITHUB_HOST = 'github.com';
@@ -39,13 +51,15 @@ query RemogramPrView($owner: String!, $repo: String!, $number: Int!) {
39
51
  const AUTH_CAPABILITIES = [
40
52
  'repo_status',
41
53
  'ref_compare',
54
+ 'ref_inventory',
55
+ 'cr_inventory',
42
56
  'pr_status',
43
57
  'pr_checks',
44
58
  'merge_plan',
45
59
  'sync_plan',
46
60
  ];
47
61
 
48
- const STRUCTURED_COMMANDS = AUTH_CAPABILITIES.map((name) => ({ name, implemented: true }));
62
+ const STRUCTURED_COMMANDS = apiProviderCommands();
49
63
 
50
64
  export function githubToken() {
51
65
  if (process.env.GITHUB_TOKEN) return { token: process.env.GITHUB_TOKEN, env: 'GITHUB_TOKEN' };
@@ -152,22 +166,77 @@ export async function githubFetch(config, parsed, path, options = {}) {
152
166
  });
153
167
  }
154
168
 
155
- const MAX_CHECK_PAGES = 50;
169
+ const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
156
170
 
157
- export async function githubFetchPaginated(config, parsed, path, slice) {
158
- const base = apiBase(config, parsed);
159
- const { token } = requireToken();
171
+ export function resolveGitHubLinkNextPage({ trustedOrigin, currentUrl, linkHeader, pageIndex, maxPages }) {
172
+ const nextRaw = parseLinkHeader(linkHeader).next ?? null;
173
+ if (!nextRaw) {
174
+ return { nextUrl: null, truncated: false };
175
+ }
176
+ if (!isTrustedPaginationUrl(trustedOrigin, nextRaw, currentUrl)) {
177
+ return { nextUrl: null, truncated: true };
178
+ }
179
+ if (pageIndex === maxPages - 1) {
180
+ return { nextUrl: null, truncated: true };
181
+ }
182
+ return { nextUrl: new URL(nextRaw, currentUrl).href, truncated: false };
183
+ }
184
+
185
+ async function paginateGitHubLinkPages({
186
+ trustedOrigin,
187
+ startUrl,
188
+ token,
189
+ initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
190
+ mapPageItems,
191
+ }) {
160
192
  const all = [];
161
- let url = `${base}${path}`;
193
+ let truncated = false;
194
+ let url = startUrl;
195
+ let activeLimit = initialLimit;
162
196
  for (let page = 0; page < MAX_CHECK_PAGES && url; page += 1) {
163
- const { body, headers } = await fetchJsonWithMeta(url, {
164
- headers: authHeaders(token),
165
- });
166
- all.push(...slice(body));
197
+ const currentUrl = url;
198
+ let usedLimit = activeLimit;
199
+ const { body, headers } = await fetchWithIngestPageBackoff(
200
+ (attemptUrl) =>
201
+ fetchJsonWithMeta(attemptUrl, {
202
+ headers: authHeaders(token),
203
+ }),
204
+ (limit) => {
205
+ usedLimit = limit;
206
+ return withPerPageParam(currentUrl, limit);
207
+ },
208
+ activeLimit,
209
+ );
210
+ activeLimit = usedLimit;
211
+ all.push(...mapPageItems(body));
167
212
  const linkHeader = headers?.get?.('link') ?? headers?.get?.('Link') ?? null;
168
- url = parseLinkHeader(linkHeader).next ?? null;
213
+ const linkPage = resolveGitHubLinkNextPage({
214
+ trustedOrigin,
215
+ currentUrl,
216
+ linkHeader,
217
+ pageIndex: page,
218
+ maxPages: MAX_CHECK_PAGES,
219
+ });
220
+ if (linkPage.truncated) {
221
+ truncated = true;
222
+ }
223
+ url = linkPage.nextUrl ? withPerPageParam(linkPage.nextUrl, activeLimit) : null;
169
224
  }
170
- return all;
225
+ return { items: all, truncated };
226
+ }
227
+
228
+ export async function githubFetchPaginated(config, parsed, path, slice) {
229
+ const base = apiBase(config, parsed);
230
+ const trustedOrigin = new URL(base).origin;
231
+ const { token } = requireToken();
232
+ const pageQuery = `${path.includes('?') ? '&' : '?'}per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
233
+ const startUrl = `${base}${path}${pageQuery}`;
234
+ return paginateGitHubLinkPages({
235
+ trustedOrigin,
236
+ startUrl,
237
+ token,
238
+ mapPageItems: (body) => slice(body),
239
+ });
171
240
  }
172
241
 
173
242
  export function graphqlEndpoint(config, parsed = {}) {
@@ -263,15 +332,21 @@ export async function repoStatus(ctx) {
263
332
  }
264
333
 
265
334
  export function providerCapabilities() {
335
+ const check_sources = ['commit_statuses', 'check_runs'];
266
336
  return {
267
337
  commands: STRUCTURED_COMMANDS,
268
338
  auth_envs: ['GITHUB_TOKEN', 'GH_TOKEN'],
269
- check_sources: ['commit_statuses', 'check_runs'],
339
+ check_sources,
270
340
  mergeability_confidence: 'derived',
271
341
  host_binding: 'verified_remote_host',
272
342
  pagination: 'supported',
273
343
  write_support: false,
274
344
  ...forgeIngestCapabilityFacts(),
345
+ ...checkPaginationCapabilityFacts({
346
+ strategy: 'link_header',
347
+ pageSizeParam: 'per_page',
348
+ sourceCount: check_sources.length,
349
+ }),
275
350
  };
276
351
  }
277
352
 
@@ -382,12 +457,14 @@ export async function prChecks(ctx, opts) {
382
457
 
383
458
  const statusPath = repoApiPath(ctx.config, 'commits', sha, 'statuses');
384
459
  const checkRunsPath = repoApiPath(ctx.config, 'commits', sha, 'check-runs');
385
- const [statusRecords, checkRunRecords] = await Promise.all([
460
+ const [statusResult, checkRunResult] = await Promise.all([
386
461
  githubFetchPaginated(ctx.config, ctx.parsed, statusPath, (body) =>
387
462
  Array.isArray(body) ? body : [],
388
463
  ),
389
464
  githubFetchPaginated(ctx.config, ctx.parsed, checkRunsPath, (body) => body?.check_runs ?? []),
390
465
  ]);
466
+ const statusRecords = statusResult.items;
467
+ const checkRunRecords = checkRunResult.items;
391
468
  const mappedStatuses = statusRecords.map((s) => ({
392
469
  context: sanitizeField(s.context),
393
470
  state: normalizeCommitStatusState(s.state),
@@ -399,18 +476,19 @@ export async function prChecks(ctx, opts) {
399
476
  description: sanitizeField(checkRunDescription(run)),
400
477
  }));
401
478
  const mapped = [...mappedStatuses, ...mappedCheckRuns];
402
- return { head_sha: sha, check_conclusion: summarizeChecks(mapped), statuses: mapped };
479
+ const checks_truncated = statusResult.truncated || checkRunResult.truncated;
480
+ return {
481
+ head_sha: sha,
482
+ check_conclusion: summarizeChecks(mapped),
483
+ checks_truncated,
484
+ statuses: mapped,
485
+ };
403
486
  }
404
487
 
405
488
  export async function mergePlan(ctx, opts) {
406
489
  const view = await prView(ctx, opts);
407
490
  const checks = await prChecks(ctx, { number: view.pr_number });
408
- const blockers = [];
409
- if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
410
- if (view.state !== 'open') blockers.push('pr_not_open');
411
- if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
412
- if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
413
- if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
491
+ const blockers = mergeBlockersFromFacts(view, checks);
414
492
  return {
415
493
  pr_number: view.pr_number,
416
494
  mergeability: view.mergeability,
@@ -419,6 +497,68 @@ export async function mergePlan(ctx, opts) {
419
497
  };
420
498
  }
421
499
 
500
+ export async function listOpenPullsWithMeta(ctx, opts = {}) {
501
+ apiBase(ctx.config, ctx.parsed);
502
+ requireToken();
503
+ const base = apiBase(ctx.config, ctx.parsed);
504
+ const { token } = requireToken();
505
+ const listLimit =
506
+ opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
507
+ ? Number(opts.limit)
508
+ : null;
509
+ const GITHUB_PAGE_SIZE = 100;
510
+ const all = [];
511
+ let listTruncated = false;
512
+
513
+ if (listLimit == null) {
514
+ const trustedOrigin = new URL(base).origin;
515
+ const startUrl = `${base}${repoApiPath(ctx.config, 'pulls')}?state=open`;
516
+ const { items: linkItems, truncated: linkTruncated } = await paginateGitHubLinkPages({
517
+ trustedOrigin,
518
+ startUrl,
519
+ token,
520
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
521
+ });
522
+ all.push(...linkItems);
523
+ listTruncated = linkTruncated;
524
+ } else {
525
+ const { items: limitItems, list_truncated: limitTruncated } = await paginateOffsetListPages({
526
+ pageSize: GITHUB_PAGE_SIZE,
527
+ 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
+ maxPagesTruncatesWithLimit: true,
531
+ fetchPage: async ({ page, limit }) => {
532
+ const pageUrl = `${base}${repoApiPath(ctx.config, 'pulls')}?state=open&page=${page}`;
533
+ const { body } = await fetchJsonWithMeta(withPerPageParam(pageUrl, limit), {
534
+ headers: authHeaders(token),
535
+ });
536
+ return Array.isArray(body) ? body : [];
537
+ },
538
+ });
539
+ all.push(...limitItems);
540
+ listTruncated = limitTruncated;
541
+ }
542
+
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);
549
+ }
550
+ return { numbers, list_truncated: listTruncated };
551
+ }
552
+
553
+ export async function listOpenPulls(ctx, opts = {}) {
554
+ const meta = await listOpenPullsWithMeta(ctx, opts);
555
+ return meta.numbers;
556
+ }
557
+
558
+ export async function crInventorySlice(ctx, opts = {}) {
559
+ return crInventory(ctx, { listOpenPulls, listOpenPullsWithMeta, prView, prChecks }, opts);
560
+ }
561
+
422
562
  export async function syncPlan(ctx, remoteName = 'origin') {
423
563
  assertGitRemote(remoteName, 'remote');
424
564
  apiBase(ctx.config, ctx.parsed);
@@ -452,6 +592,9 @@ export const provider = {
452
592
  providerCapabilities,
453
593
  repoStatus,
454
594
  refsCompare,
595
+ refsInventory,
596
+ listOpenPulls,
597
+ crInventory: crInventorySlice,
455
598
  prView,
456
599
  prChecks,
457
600
  mergePlan,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/provider-github-api",
3
- "version": "0.1.0-beta.1",
3
+ "version": "0.1.0-beta.3",
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.1"
25
+ "@remogram/core": "0.1.0-beta.3"
26
26
  }
27
27
  }