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

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.
@@ -0,0 +1,13 @@
1
+ /** @type {((ctx: object, opts: { branchRef: string }) => Promise<object>) | null} */
2
+ let branchProtectionImpl = null;
3
+
4
+ export function setBranchProtectionImpl(fn) {
5
+ branchProtectionImpl = fn;
6
+ }
7
+
8
+ export async function resolveBranchProtection(ctx, opts) {
9
+ if (typeof branchProtectionImpl !== 'function') {
10
+ throw new Error('branch protection impl not registered');
11
+ }
12
+ return branchProtectionImpl(ctx, opts);
13
+ }
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,58 @@ import {
9
10
  gitRevParse,
10
11
  gitCurrentBranch,
11
12
  gitAheadBehind,
13
+ refsInventory,
14
+ crInventory,
15
+ buildMergePlanFromProviderFacts,
12
16
  ERROR_CODES,
13
17
  forgeError,
18
+ LIVE_REACHABILITY_TIMEOUT_MS,
14
19
  forgeIngestCapabilityFacts,
20
+ forgeWriteFieldCapabilityFacts,
21
+ checkPaginationCapabilityFacts,
22
+ openPullListCapabilityFacts,
23
+ DEFAULT_CHECK_STATUS_PAGE_SIZE,
24
+ MAX_CHECK_STATUS_PAGES,
25
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
26
+ fetchWithIngestPageBackoff,
27
+ paginateOffsetListPages,
28
+ fetchPageWithIngestBackoff,
29
+ withPerPageParam,
30
+ apiProviderCommands,
31
+ normalizeCrInventorySort,
32
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
33
+ isCrInventoryFastPathEligible,
34
+ validateFastPathPageLength,
35
+ isNumberSortFastPathEligible,
36
+ isNumberSortFullCollectRequired,
37
+ resolvePaginatedEntryCount,
38
+ resolveListTruncatedWithTrustedTotal,
39
+ orderOpenPullNumbers,
40
+ buildOpenPullListMeta,
41
+ githubOpenPullSortQuery,
42
+ buildProviderIdentityFromGitHubUser,
43
+ buildBranchProtectionFromGitHubProtection,
44
+ buildPrChecksBody,
45
+ buildCrFilesBody,
46
+ buildCrFilesFromGiteaFiles,
47
+ buildCrCommentsBody,
48
+ buildCrCommentsFromGiteaComments,
49
+ parseSinceObservedAt,
50
+ buildForgeChangesFromGiteaPulls,
51
+ buildChecksConclusionObservedEvent,
52
+ appendForgeChangeEvents,
53
+ buildCommitStatusSetBody,
54
+ idempotencyPacketFields,
55
+ parseStatusSetArgs,
56
+ normalizeStatusSetState,
57
+ statusSetIdempotencyScanCapabilityFacts,
58
+ MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
59
+ assertWriteCommandConfigured,
15
60
  } from '@remogram/core';
61
+ import {
62
+ resolveBranchProtection,
63
+ setBranchProtectionImpl,
64
+ } from './branch-protection-internal.js';
16
65
 
17
66
  const PUBLIC_GITHUB_HOST = 'github.com';
18
67
  const PUBLIC_GITHUB_API = 'https://api.github.com';
@@ -39,13 +88,27 @@ query RemogramPrView($owner: String!, $repo: String!, $number: Int!) {
39
88
  const AUTH_CAPABILITIES = [
40
89
  'repo_status',
41
90
  'ref_compare',
91
+ 'ref_inventory',
92
+ 'cr_inventory',
42
93
  'pr_status',
43
94
  'pr_checks',
44
95
  'merge_plan',
45
96
  'sync_plan',
97
+ 'status_set',
98
+ 'whoami',
99
+ 'branch_protection',
100
+ 'cr_files',
101
+ 'cr_comments',
102
+ 'forge_changes',
46
103
  ];
47
104
 
48
- const STRUCTURED_COMMANDS = AUTH_CAPABILITIES.map((name) => ({ name, implemented: true }));
105
+ const STRUCTURED_COMMANDS = apiProviderCommands({
106
+ branchProtectionImplemented: true,
107
+ crFilesImplemented: true,
108
+ crCommentsImplemented: true,
109
+ forgeChangesImplemented: true,
110
+ statusSetImplemented: true,
111
+ });
49
112
 
50
113
  export function githubToken() {
51
114
  if (process.env.GITHUB_TOKEN) return { token: process.env.GITHUB_TOKEN, env: 'GITHUB_TOKEN' };
@@ -152,22 +215,122 @@ export async function githubFetch(config, parsed, path, options = {}) {
152
215
  });
153
216
  }
154
217
 
155
- const MAX_CHECK_PAGES = 50;
156
-
157
- export async function githubFetchPaginated(config, parsed, path, slice) {
158
- const base = apiBase(config, parsed);
218
+ export async function apiReachability(ctx) {
219
+ if (!githubToken()) {
220
+ throw Object.assign(new Error('GitHub token not set'), {
221
+ forgeError: forgeError(
222
+ ERROR_CODES.UNAUTHENTICATED_PROVIDER,
223
+ 'GITHUB_TOKEN or GH_TOKEN not set',
224
+ ),
225
+ });
226
+ }
159
227
  const { token } = requireToken();
228
+ const url = `${apiBase(ctx.config, ctx.parsed)}${repoApiPath(ctx.config)}`;
229
+ await fetchJson(
230
+ url,
231
+ { headers: authHeaders(token) },
232
+ LIVE_REACHABILITY_TIMEOUT_MS,
233
+ );
234
+ return { repo_accessible: true };
235
+ }
236
+
237
+ const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
238
+
239
+ export function resolveGitHubLinkNextPage({ trustedOrigin, currentUrl, linkHeader, pageIndex, maxPages }) {
240
+ const nextRaw = parseLinkHeader(linkHeader).next ?? null;
241
+ if (!nextRaw) {
242
+ return { nextUrl: null, truncated: false };
243
+ }
244
+ if (!isTrustedPaginationUrl(trustedOrigin, nextRaw, currentUrl)) {
245
+ return { nextUrl: null, truncated: true };
246
+ }
247
+ if (pageIndex === maxPages - 1) {
248
+ return { nextUrl: null, truncated: true };
249
+ }
250
+ return { nextUrl: new URL(nextRaw, currentUrl).href, truncated: false };
251
+ }
252
+
253
+ async function paginateGitHubLinkPages({
254
+ trustedOrigin,
255
+ startUrl,
256
+ token,
257
+ initialLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE,
258
+ mapPageItems,
259
+ seededFirstPage = null,
260
+ }) {
160
261
  const all = [];
161
- let url = `${base}${path}`;
162
- for (let page = 0; page < MAX_CHECK_PAGES && url; page += 1) {
163
- const { body, headers } = await fetchJsonWithMeta(url, {
164
- headers: authHeaders(token),
262
+ let truncated = false;
263
+ let walkedCount = 0;
264
+ let url = startUrl;
265
+ let activeLimit = initialLimit;
266
+ let pageIndex = 0;
267
+
268
+ if (seededFirstPage) {
269
+ const { items, linkHeader, currentUrl, usedLimit } = seededFirstPage;
270
+ activeLimit = usedLimit;
271
+ const mapped = mapPageItems(items);
272
+ walkedCount += mapped.length;
273
+ all.push(...mapped);
274
+ const linkPage = resolveGitHubLinkNextPage({
275
+ trustedOrigin,
276
+ currentUrl,
277
+ linkHeader,
278
+ pageIndex: 0,
279
+ maxPages: MAX_CHECK_PAGES,
165
280
  });
166
- all.push(...slice(body));
281
+ if (linkPage.truncated) {
282
+ truncated = true;
283
+ }
284
+ url = linkPage.nextUrl ? withPerPageParam(linkPage.nextUrl, activeLimit) : null;
285
+ pageIndex = 1;
286
+ }
287
+
288
+ for (let page = pageIndex; page < MAX_CHECK_PAGES && url; page += 1) {
289
+ const currentUrl = url;
290
+ let usedLimit = activeLimit;
291
+ const { body, headers } = await fetchWithIngestPageBackoff(
292
+ (attemptUrl) =>
293
+ fetchJsonWithMeta(attemptUrl, {
294
+ headers: authHeaders(token),
295
+ }),
296
+ (limit) => {
297
+ usedLimit = limit;
298
+ return withPerPageParam(currentUrl, limit);
299
+ },
300
+ activeLimit,
301
+ );
302
+ activeLimit = usedLimit;
303
+ const mapped = mapPageItems(body);
304
+ walkedCount += mapped.length;
305
+ all.push(...mapped);
167
306
  const linkHeader = headers?.get?.('link') ?? headers?.get?.('Link') ?? null;
168
- url = parseLinkHeader(linkHeader).next ?? null;
307
+ const linkPage = resolveGitHubLinkNextPage({
308
+ trustedOrigin,
309
+ currentUrl,
310
+ linkHeader,
311
+ pageIndex: page,
312
+ maxPages: MAX_CHECK_PAGES,
313
+ });
314
+ if (linkPage.truncated) {
315
+ truncated = true;
316
+ }
317
+ url = linkPage.nextUrl ? withPerPageParam(linkPage.nextUrl, activeLimit) : null;
169
318
  }
170
- return all;
319
+ return { items: all, truncated, walked_count: walkedCount };
320
+ }
321
+
322
+ export async function githubFetchPaginated(config, parsed, path, slice) {
323
+ const base = apiBase(config, parsed);
324
+ const trustedOrigin = new URL(base).origin;
325
+ const { token } = requireToken();
326
+ const pageQuery = `${path.includes('?') ? '&' : '?'}per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
327
+ const startUrl = `${base}${path}${pageQuery}`;
328
+ return paginateGitHubLinkPages({
329
+ trustedOrigin,
330
+ startUrl,
331
+ token,
332
+ mapPageItems: (body) => slice(body),
333
+ });
171
334
  }
172
335
 
173
336
  export function graphqlEndpoint(config, parsed = {}) {
@@ -262,16 +425,28 @@ export async function repoStatus(ctx) {
262
425
  };
263
426
  }
264
427
 
265
- export function providerCapabilities() {
428
+ export function providerCapabilities(ctx = {}) {
429
+ const check_sources = ['commit_statuses', 'check_runs'];
266
430
  return {
267
431
  commands: STRUCTURED_COMMANDS,
268
432
  auth_envs: ['GITHUB_TOKEN', 'GH_TOKEN'],
269
- check_sources: ['commit_statuses', 'check_runs'],
433
+ check_sources,
270
434
  mergeability_confidence: 'derived',
271
435
  host_binding: 'verified_remote_host',
272
436
  pagination: 'supported',
273
- write_support: false,
437
+ write_support: true,
438
+ write_commands: ['status_set'],
274
439
  ...forgeIngestCapabilityFacts(),
440
+ ...forgeWriteFieldCapabilityFacts(ctx.writeFieldPolicy),
441
+ ...statusSetIdempotencyScanCapabilityFacts(),
442
+ ...checkPaginationCapabilityFacts({
443
+ strategy: 'link_header',
444
+ pageSizeParam: 'per_page',
445
+ sourceCount: check_sources.length,
446
+ }),
447
+ ...openPullListCapabilityFacts({
448
+ totalCountSource: 'search_api',
449
+ }),
275
450
  };
276
451
  }
277
452
 
@@ -288,10 +463,10 @@ export async function refsCompare(ctx, baseRef, headRef) {
288
463
  }
289
464
  const counts = gitAheadBehind(ctx.cwd, baseSha, headSha);
290
465
  return {
291
- base_ref: sanitizeField(baseRef),
292
- base_sha: baseSha,
293
- head_ref: sanitizeField(headRef),
294
- head_sha: headSha,
466
+ compare_base_ref: sanitizeField(baseRef),
467
+ compare_base_sha: baseSha,
468
+ compare_head_ref: sanitizeField(headRef),
469
+ compare_head_sha: headSha,
295
470
  ...counts,
296
471
  };
297
472
  }
@@ -313,10 +488,10 @@ export async function prView(ctx, opts) {
313
488
  url: sanitizeUrl(pr.html_url ?? pr.url),
314
489
  title: sanitizeField(pr.title),
315
490
  state: sanitizeField(pr.state),
316
- base_ref: sanitizeField(pr.base?.ref),
317
- base_sha: sanitizeField(pr.base?.sha),
318
- head_ref: sanitizeField(pr.head?.ref),
319
- head_sha: sanitizeField(pr.head?.sha),
491
+ forge_target_branch_ref: sanitizeField(pr.base?.ref),
492
+ forge_target_sha: sanitizeField(pr.base?.sha),
493
+ forge_source_branch_ref: sanitizeField(pr.head?.ref),
494
+ forge_source_sha: sanitizeField(pr.head?.sha),
320
495
  mergeability: mergeability(pr),
321
496
  };
322
497
  }
@@ -362,6 +537,7 @@ export async function prChecks(ctx, opts) {
362
537
  apiBase(ctx.config, ctx.parsed);
363
538
  requireToken();
364
539
  let sha;
540
+ let requiredContexts = [];
365
541
  if (opts.ref) {
366
542
  assertGitRef(opts.ref, 'ref');
367
543
  sha = gitRevParse(ctx.cwd, opts.ref);
@@ -373,6 +549,11 @@ export async function prChecks(ctx, opts) {
373
549
  } else {
374
550
  const pr = await fetchPullGraphql(ctx.config, ctx.parsed, opts.number);
375
551
  sha = pr.head?.sha;
552
+ const targetBranch = pr.base?.ref;
553
+ if (targetBranch) {
554
+ const protection = await resolveBranchProtection(ctx, { branchRef: targetBranch });
555
+ requiredContexts = protection.required_status_contexts ?? [];
556
+ }
376
557
  }
377
558
  if (!sha) {
378
559
  throw Object.assign(new Error('No SHA'), {
@@ -382,43 +563,399 @@ export async function prChecks(ctx, opts) {
382
563
 
383
564
  const statusPath = repoApiPath(ctx.config, 'commits', sha, 'statuses');
384
565
  const checkRunsPath = repoApiPath(ctx.config, 'commits', sha, 'check-runs');
385
- const [statusRecords, checkRunRecords] = await Promise.all([
566
+ const [statusResult, checkRunResult] = await Promise.all([
386
567
  githubFetchPaginated(ctx.config, ctx.parsed, statusPath, (body) =>
387
568
  Array.isArray(body) ? body : [],
388
569
  ),
389
570
  githubFetchPaginated(ctx.config, ctx.parsed, checkRunsPath, (body) => body?.check_runs ?? []),
390
571
  ]);
572
+ const statusRecords = statusResult.items;
573
+ const checkRunRecords = checkRunResult.items;
391
574
  const mappedStatuses = statusRecords.map((s) => ({
392
575
  context: sanitizeField(s.context),
393
576
  state: normalizeCommitStatusState(s.state),
394
577
  description: sanitizeField(s.description),
578
+ ...(s.target_url ? { target_url: sanitizeField(s.target_url) } : {}),
579
+ sha,
580
+ source: 'commit_status',
395
581
  }));
396
582
  const mappedCheckRuns = checkRunRecords.map((run) => ({
397
583
  context: sanitizeField(run.name),
398
584
  state: normalizeCheckRunState(run),
399
585
  description: sanitizeField(checkRunDescription(run)),
586
+ ...(run.details_url ? { target_url: sanitizeField(run.details_url) } : {}),
587
+ sha: sanitizeField(run.head_sha) || sha,
588
+ source: 'check_run',
400
589
  }));
401
590
  const mapped = [...mappedStatuses, ...mappedCheckRuns];
402
- return { head_sha: sha, check_conclusion: summarizeChecks(mapped), statuses: mapped };
591
+ const checks_truncated = statusResult.truncated || checkRunResult.truncated;
592
+ return buildPrChecksBody({
593
+ forge_source_sha: sha,
594
+ check_conclusion: summarizeChecks(mapped),
595
+ checks_truncated,
596
+ statuses: mapped,
597
+ required_contexts: requiredContexts,
598
+ });
403
599
  }
404
600
 
405
601
  export async function mergePlan(ctx, opts) {
406
- const view = await prView(ctx, opts);
407
- 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');
602
+ return buildMergePlanFromProviderFacts(ctx, opts, { prView, prChecks, crFiles });
603
+ }
604
+
605
+ export async function crFiles(ctx, { number }) {
606
+ if (number == null) {
607
+ throw Object.assign(new Error('--number required'), {
608
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR changed paths'),
609
+ });
610
+ }
611
+ const base = apiBase(ctx.config, ctx.parsed);
612
+ const trustedOrigin = new URL(base).origin;
613
+ const { token } = requireToken();
614
+ const startUrl = `${base}${repoApiPath(ctx.config, 'pulls', number, 'files')}?per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
615
+ const { items, truncated, walked_count } = await paginateGitHubLinkPages({
616
+ trustedOrigin,
617
+ startUrl,
618
+ token,
619
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
620
+ });
621
+ const body = buildCrFilesFromGiteaFiles(number, items);
622
+ if (truncated) {
623
+ return buildCrFilesBody({
624
+ pr_number: body.pr_number,
625
+ changed_paths: body.changed_paths,
626
+ paths_truncated: true,
627
+ path_count: walked_count,
628
+ });
629
+ }
630
+ return body;
631
+ }
632
+
633
+ export async function crComments(ctx, { number }) {
634
+ if (number == null) {
635
+ throw Object.assign(new Error('--number required'), {
636
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR review comments'),
637
+ });
638
+ }
639
+ const base = apiBase(ctx.config, ctx.parsed);
640
+ const trustedOrigin = new URL(base).origin;
641
+ const { token } = requireToken();
642
+ const startUrl = `${base}${repoApiPath(ctx.config, 'pulls', number, 'comments')}?per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
643
+ const { items, truncated, walked_count } = await paginateGitHubLinkPages({
644
+ trustedOrigin,
645
+ startUrl,
646
+ token,
647
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
648
+ });
649
+ const body = buildCrCommentsFromGiteaComments(number, items);
650
+ if (truncated) {
651
+ return buildCrCommentsBody({
652
+ pr_number: body.pr_number,
653
+ comments: body.comments,
654
+ comments_truncated: true,
655
+ comment_count: walked_count,
656
+ });
657
+ }
658
+ return body;
659
+ }
660
+
661
+ export async function forgeChanges(ctx, { since }) {
662
+ requireToken();
663
+ const sinceIso = parseSinceObservedAt(since);
664
+ const base = apiBase(ctx.config, ctx.parsed);
665
+ const trustedOrigin = new URL(base).origin;
666
+ const { token } = requireToken();
667
+ const startUrl = `${base}${repoApiPath(ctx.config, 'pulls')}?state=all&sort=updated&direction=desc&per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
668
+ const { items, truncated } = await paginateGitHubLinkPages({
669
+ trustedOrigin,
670
+ startUrl,
671
+ token,
672
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
673
+ });
674
+ let body = buildForgeChangesFromGiteaPulls(sinceIso, items, { listTruncated: truncated });
675
+ const checkNumbers = new Set();
676
+ for (const event of body.events) {
677
+ if (event.kind === 'pr_opened' || event.kind === 'head_sha_moved') {
678
+ checkNumbers.add(event.pr_number);
679
+ }
680
+ }
681
+ const checkEvents = [];
682
+ for (const number of checkNumbers) {
683
+ const checks = await prChecks(ctx, { number });
684
+ checkEvents.push(buildChecksConclusionObservedEvent(number, checks));
685
+ }
686
+ if (checkEvents.length > 0) {
687
+ body = appendForgeChangeEvents(body, checkEvents, { listTruncated: truncated });
688
+ }
689
+ return body;
690
+ }
691
+
692
+ const GITHUB_OPEN_PULL_COMPLIANT_MAX =
693
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
694
+ const GITHUB_PAGE_SIZE = 100;
695
+
696
+ function githubOpenPullsListPath(config, sliceSort) {
697
+ const params = new URLSearchParams({ state: 'open', ...githubOpenPullSortQuery(sliceSort) });
698
+ return `${repoApiPath(config, 'pulls')}?${params.toString()}`;
699
+ }
700
+
701
+ async function fetchGitHubOpenPullSearchTotal(ctx) {
702
+ const base = apiBase(ctx.config, ctx.parsed);
703
+ const { token } = requireToken();
704
+ const owner = encodeURIComponent(ctx.config.owner);
705
+ const repo = encodeURIComponent(ctx.config.repo);
706
+ const searchPath = `/search/issues?q=repo:${owner}/${repo}+is:pr+state:open&per_page=1`;
707
+ try {
708
+ const { body } = await fetchJsonWithMeta(`${base}${searchPath}`, {
709
+ headers: authHeaders(token),
710
+ });
711
+ if (!body || body.incomplete_results === true) return null;
712
+ const total = Number(body.total_count);
713
+ const maxTrusted = GITHUB_OPEN_PULL_COMPLIANT_MAX * 2;
714
+ if (!Number.isInteger(total) || total <= 0 || total > maxTrusted) return null;
715
+ return total;
716
+ } catch {
717
+ return null;
718
+ }
719
+ }
720
+
721
+ async function probeGitHubOpenPullPageOne(ctx, retainMax, sliceSort) {
722
+ const totalCount = await fetchGitHubOpenPullSearchTotal(ctx);
723
+ if (totalCount == null) return null;
724
+
725
+ const base = apiBase(ctx.config, ctx.parsed);
726
+ const { token } = requireToken();
727
+ const requestLimit = Math.min(retainMax, GITHUB_PAGE_SIZE);
728
+ const listPath = githubOpenPullsListPath(ctx.config, sliceSort);
729
+ const listUrl = withPerPageParam(`${base}${listPath}`, requestLimit);
730
+ try {
731
+ const { body, headers } = await fetchJsonWithMeta(listUrl, {
732
+ headers: authHeaders(token),
733
+ });
734
+ if (!Array.isArray(body)) return null;
735
+ const listTruncated = totalCount > GITHUB_OPEN_PULL_COMPLIANT_MAX;
736
+ const linkHeader = headers?.get?.('link') ?? headers?.get?.('Link') ?? null;
737
+ return { body, totalCount, listTruncated, requestLimit, listUrl, linkHeader };
738
+ } catch {
739
+ return null;
740
+ }
741
+ }
742
+
743
+ function buildGitHubOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, listTruncated) {
744
+ let numbers = orderOpenPullNumbers(body, (pr) => pr?.number, sliceSort);
745
+ if (numbers.length > retainMax) numbers = numbers.slice(0, retainMax);
746
+ return buildOpenPullListMeta({
747
+ totalCount,
748
+ numbers,
749
+ listTruncated,
750
+ sliceSort,
751
+ });
752
+ }
753
+
754
+ function githubProbePaginationOpts(probe) {
755
+ const { body, totalCount, requestLimit, listUrl, linkHeader } = probe;
414
756
  return {
415
- pr_number: view.pr_number,
416
- mergeability: view.mergeability,
417
- checks_conclusion: checks.check_conclusion,
418
- blockers,
757
+ trustedTotalCount: totalCount,
758
+ seededFirstPage: { items: body, usedLimit: requestLimit, listUrl, linkHeader },
759
+ };
760
+ }
761
+
762
+ async function paginateGitHubOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
763
+ const {
764
+ trustedTotalCount = null,
765
+ seededFirstPage = null,
766
+ numberSortFullCollect = false,
767
+ } = paginationOpts;
768
+ const base = apiBase(ctx.config, ctx.parsed);
769
+ const { token } = requireToken();
770
+ const listLimit =
771
+ opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
772
+ ? Number(opts.limit)
773
+ : null;
774
+ const all = [];
775
+ let listTruncated = false;
776
+ let walkedCount = 0;
777
+ const listPath = githubOpenPullsListPath(ctx.config, sliceSort);
778
+ const retainMax =
779
+ listLimit == null &&
780
+ opts.retain_max != null &&
781
+ Number.isInteger(Number(opts.retain_max)) &&
782
+ Number(opts.retain_max) > 0
783
+ ? Number(opts.retain_max)
784
+ : null;
785
+
786
+ if (listLimit == null && numberSortFullCollect) {
787
+ if (seededFirstPage?.linkHeader) {
788
+ const trustedOrigin = new URL(base).origin;
789
+ const startUrl = seededFirstPage.listUrl ?? `${base}${listPath}`;
790
+ const linkSeed = {
791
+ items: seededFirstPage.items,
792
+ linkHeader: seededFirstPage.linkHeader,
793
+ currentUrl: startUrl,
794
+ usedLimit: seededFirstPage.usedLimit,
795
+ };
796
+ const {
797
+ items: linkItems,
798
+ truncated: linkTruncated,
799
+ walked_count: linkWalkedCount,
800
+ } = await paginateGitHubLinkPages({
801
+ trustedOrigin,
802
+ startUrl,
803
+ token,
804
+ initialLimit: seededFirstPage.usedLimit ?? DEFAULT_CHECK_STATUS_PAGE_SIZE,
805
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
806
+ seededFirstPage: linkSeed,
807
+ });
808
+ all.push(...linkItems);
809
+ listTruncated = linkTruncated;
810
+ walkedCount = linkWalkedCount;
811
+ } else {
812
+ const {
813
+ items: offsetItems,
814
+ list_truncated: offsetTruncated,
815
+ walked_count: offsetWalkedCount,
816
+ } = await paginateOffsetListPages({
817
+ pageSize: GITHUB_PAGE_SIZE,
818
+ retainMax: null,
819
+ trustedEntryCount: trustedTotalCount,
820
+ seededFirstPage: seededFirstPage
821
+ ? { items: seededFirstPage.items, usedLimit: seededFirstPage.usedLimit }
822
+ : null,
823
+ fetchPage: async ({ page, limit }) => {
824
+ const pageUrl = `${base}${listPath}&page=${page}`;
825
+ const { body } = await fetchJsonWithMeta(withPerPageParam(pageUrl, limit), {
826
+ headers: authHeaders(token),
827
+ });
828
+ return Array.isArray(body) ? body : [];
829
+ },
830
+ });
831
+ all.push(...offsetItems);
832
+ listTruncated = offsetTruncated;
833
+ walkedCount = offsetWalkedCount;
834
+ }
835
+ } else if (listLimit == null) {
836
+ const trustedOrigin = new URL(base).origin;
837
+ const startUrl = `${base}${listPath}`;
838
+ const linkSeed = seededFirstPage
839
+ ? {
840
+ items: seededFirstPage.items,
841
+ linkHeader: seededFirstPage.linkHeader,
842
+ currentUrl: seededFirstPage.listUrl,
843
+ usedLimit: seededFirstPage.usedLimit,
844
+ }
845
+ : null;
846
+ const {
847
+ items: linkItems,
848
+ truncated: linkTruncated,
849
+ walked_count: linkWalkedCount,
850
+ } = await paginateGitHubLinkPages({
851
+ trustedOrigin,
852
+ startUrl,
853
+ token,
854
+ initialLimit: seededFirstPage?.usedLimit ?? DEFAULT_CHECK_STATUS_PAGE_SIZE,
855
+ mapPageItems: (body) => (Array.isArray(body) ? body : []),
856
+ seededFirstPage: linkSeed,
857
+ });
858
+ all.push(...linkItems);
859
+ listTruncated = linkTruncated;
860
+ walkedCount = linkWalkedCount;
861
+ } else {
862
+ const {
863
+ items: limitItems,
864
+ list_truncated: limitTruncated,
865
+ walked_count: limitWalkedCount,
866
+ } = await paginateOffsetListPages({
867
+ pageSize: GITHUB_PAGE_SIZE,
868
+ listLimit,
869
+ maxPagesTruncatesWithLimit: true,
870
+ trustedEntryCount: trustedTotalCount,
871
+ seededFirstPage: seededFirstPage
872
+ ? { items: seededFirstPage.items, usedLimit: seededFirstPage.usedLimit }
873
+ : null,
874
+ fetchPage: async ({ page, limit }) => {
875
+ const pageUrl = `${base}${listPath}&page=${page}`;
876
+ const { body } = await fetchJsonWithMeta(withPerPageParam(pageUrl, limit), {
877
+ headers: authHeaders(token),
878
+ });
879
+ return Array.isArray(body) ? body : [];
880
+ },
881
+ });
882
+ all.push(...limitItems);
883
+ listTruncated = limitTruncated;
884
+ walkedCount = limitWalkedCount;
885
+ }
886
+
887
+ let numbers = orderOpenPullNumbers(all, (pr) => pr?.number, sliceSort);
888
+ const outputCap = listLimit ?? retainMax;
889
+ if (outputCap != null && numbers.length > outputCap) {
890
+ numbers = numbers.slice(0, outputCap);
891
+ }
892
+ const entryCount =
893
+ trustedTotalCount != null
894
+ ? resolvePaginatedEntryCount(trustedTotalCount, walkedCount)
895
+ : undefined;
896
+ return {
897
+ numbers,
898
+ list_truncated: resolveListTruncatedWithTrustedTotal({
899
+ listTruncated,
900
+ trustedTotalCount,
901
+ walkedCount,
902
+ fullCollect: numberSortFullCollect,
903
+ }),
904
+ slice_sort: sliceSort,
905
+ ...(entryCount != null ? { entry_count: entryCount } : {}),
419
906
  };
420
907
  }
421
908
 
909
+ export async function listOpenPullsWithMeta(ctx, opts = {}) {
910
+ apiBase(ctx.config, ctx.parsed);
911
+ requireToken();
912
+ const sliceSort = normalizeCrInventorySort(opts.sort ?? DEFAULT_CR_INVENTORY_SLICE_SORT);
913
+ if (!isCrInventoryFastPathEligible(opts)) {
914
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort);
915
+ }
916
+
917
+ const retainMax = Number(opts.retain_max);
918
+ const probe = await probeGitHubOpenPullPageOne(ctx, retainMax, sliceSort);
919
+ if (!probe) {
920
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort);
921
+ }
922
+
923
+ const { body, totalCount, listTruncated, requestLimit } = probe;
924
+
925
+ if (listTruncated) {
926
+ if (body.length === 0) {
927
+ return paginateGitHubOpenPullList(ctx, opts, sliceSort, githubProbePaginationOpts(probe));
928
+ }
929
+ return buildGitHubOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, true);
930
+ }
931
+
932
+ if (
933
+ isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) &&
934
+ validateFastPathPageLength(totalCount, requestLimit, body.length)
935
+ ) {
936
+ return buildGitHubOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
937
+ }
938
+
939
+ return paginateGitHubOpenPullList(
940
+ ctx,
941
+ opts,
942
+ sliceSort,
943
+ {
944
+ ...githubProbePaginationOpts(probe),
945
+ numberSortFullCollect: isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort),
946
+ },
947
+ );
948
+ }
949
+
950
+ export async function listOpenPulls(ctx, opts = {}) {
951
+ const meta = await listOpenPullsWithMeta(ctx, opts);
952
+ return meta.numbers;
953
+ }
954
+
955
+ export async function crInventorySlice(ctx, opts = {}) {
956
+ return crInventory(ctx, { listOpenPulls, listOpenPullsWithMeta, prView, prChecks }, opts);
957
+ }
958
+
422
959
  export async function syncPlan(ctx, remoteName = 'origin') {
423
960
  assertGitRemote(remoteName, 'remote');
424
961
  apiBase(ctx.config, ctx.parsed);
@@ -447,13 +984,158 @@ export async function syncPlan(ctx, remoteName = 'origin') {
447
984
  };
448
985
  }
449
986
 
987
+ export async function whoami(ctx) {
988
+ const base = apiBase(ctx.config, ctx.parsed);
989
+ const { token } = requireToken();
990
+ const url = `${base}/user`;
991
+ const { body, headers } = await fetchJsonWithMeta(url, {
992
+ headers: authHeaders(token),
993
+ });
994
+ const scopeHeader =
995
+ headers?.get?.('x-oauth-scopes') ?? headers?.get?.('X-OAuth-Scopes') ?? null;
996
+ return buildProviderIdentityFromGitHubUser(body, scopeHeader);
997
+ }
998
+
999
+ function githubStatusRecordOrder(a, b) {
1000
+ const aUpdated = Date.parse(a?.updated_at ?? '') || 0;
1001
+ const bUpdated = Date.parse(b?.updated_at ?? '') || 0;
1002
+ if (aUpdated !== bUpdated) return aUpdated - bUpdated;
1003
+ const aId = Number(a.id) || 0;
1004
+ const bId = Number(b.id) || 0;
1005
+ return aId - bId;
1006
+ }
1007
+
1008
+ function statusSetIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
1009
+ return forgeError(
1010
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
1011
+ 'Cannot prove no commit status exists for sha+context within scan limit; retry or set manually',
1012
+ null,
1013
+ {
1014
+ idempotency_scan: {
1015
+ pages: pagesScanned,
1016
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
1017
+ page_size: pageSizeUsed,
1018
+ },
1019
+ },
1020
+ );
1021
+ }
1022
+
1023
+ /** Paginated commit-status scan for idempotent status set; fail-closed when scan cap prevents proof of absence. */
1024
+ export async function findCommitStatusByContext(ctx, sha, context) {
1025
+ const { token } = requireToken();
1026
+ const base = apiBase(ctx.config, ctx.parsed);
1027
+ const trustedOrigin = new URL(base).origin;
1028
+ const path = repoApiPath(ctx.config, 'commits', sha, 'statuses');
1029
+ const pageQuery = `${path.includes('?') ? '&' : '?'}per_page=${DEFAULT_CHECK_STATUS_PAGE_SIZE}`;
1030
+ let url = `${base}${path}${pageQuery}`;
1031
+ let bestMatch = null;
1032
+ const activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
1033
+
1034
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
1035
+ const { body, headers } = await fetchJsonWithMeta(url, {
1036
+ headers: authHeaders(token),
1037
+ });
1038
+ const items = Array.isArray(body) ? body : [];
1039
+ for (const record of items) {
1040
+ if (record?.context !== context) continue;
1041
+ if (!bestMatch || githubStatusRecordOrder(record, bestMatch) > 0) {
1042
+ bestMatch = record;
1043
+ }
1044
+ }
1045
+ const linkPage = resolveGitHubLinkNextPage({
1046
+ trustedOrigin,
1047
+ currentUrl: url,
1048
+ linkHeader: headers?.get?.('link') ?? headers?.get?.('Link'),
1049
+ pageIndex: page - 1,
1050
+ maxPages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
1051
+ });
1052
+ if (items.length < activeLimit) {
1053
+ return bestMatch;
1054
+ }
1055
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
1056
+ throw Object.assign(new Error('Commit status idempotency scan incomplete'), {
1057
+ forgeError: statusSetIdempotencyScanIncompleteError(page, activeLimit),
1058
+ });
1059
+ }
1060
+ if (!linkPage.nextUrl) {
1061
+ return bestMatch;
1062
+ }
1063
+ url = withPerPageParam(linkPage.nextUrl, activeLimit);
1064
+ }
1065
+ return bestMatch;
1066
+ }
1067
+
1068
+ export async function statusSet(ctx, args) {
1069
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'status_set');
1070
+ const { idempotencyFingerprint = null, ...rest } = args;
1071
+ const parsed = parseStatusSetArgs(rest);
1072
+ const existing = await findCommitStatusByContext(ctx, parsed.sha, parsed.context);
1073
+ if (existing) {
1074
+ const existingState = normalizeStatusSetState(existing.state ?? existing.status);
1075
+ if (existingState === parsed.state) {
1076
+ return buildCommitStatusSetBody(existing, parsed, {
1077
+ reusedExisting: true,
1078
+ idempotencyFields: idempotencyFingerprint
1079
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
1080
+ : null,
1081
+ });
1082
+ }
1083
+ }
1084
+ const payload = {
1085
+ state: parsed.state,
1086
+ context: parsed.context,
1087
+ };
1088
+ if (parsed.description != null) payload.description = parsed.description;
1089
+ if (parsed.target_url != null) payload.target_url = parsed.target_url;
1090
+ const response = await githubFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'statuses', parsed.sha), {
1091
+ method: 'POST',
1092
+ headers: { 'Content-Type': 'application/json' },
1093
+ body: JSON.stringify(payload),
1094
+ });
1095
+ return buildCommitStatusSetBody(response, parsed, {
1096
+ idempotencyFields: idempotencyFingerprint
1097
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
1098
+ : null,
1099
+ });
1100
+ }
1101
+
1102
+ export async function branchProtection(ctx, { branchRef }) {
1103
+ assertGitRef(branchRef, '--branch-ref');
1104
+ const base = apiBase(ctx.config, ctx.parsed);
1105
+ const { token } = requireToken();
1106
+ const url = `${base}${repoApiPath(ctx.config, 'branches', branchRef, 'protection')}`;
1107
+ try {
1108
+ const { body } = await fetchJsonWithMeta(url, {
1109
+ headers: authHeaders(token),
1110
+ });
1111
+ return buildBranchProtectionFromGitHubProtection(branchRef, body);
1112
+ } catch (err) {
1113
+ if (err?.status === 404) {
1114
+ return buildBranchProtectionFromGitHubProtection(branchRef, null);
1115
+ }
1116
+ throw err;
1117
+ }
1118
+ }
1119
+
450
1120
  export const provider = {
451
1121
  id: 'github-api',
452
1122
  providerCapabilities,
1123
+ apiReachability,
453
1124
  repoStatus,
454
1125
  refsCompare,
1126
+ refsInventory,
1127
+ listOpenPulls,
1128
+ crInventory: crInventorySlice,
455
1129
  prView,
456
1130
  prChecks,
457
1131
  mergePlan,
458
1132
  syncPlan,
1133
+ whoami,
1134
+ branchProtection,
1135
+ crFiles,
1136
+ crComments,
1137
+ forgeChanges,
1138
+ statusSet,
459
1139
  };
1140
+
1141
+ setBranchProtectionImpl(branchProtection);
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.10",
4
4
  "description": "GitHub API provider for remogram",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,12 +16,13 @@
16
16
  "*.js"
17
17
  ],
18
18
  "exports": {
19
- ".": "./index.js"
19
+ ".": "./index.js",
20
+ "./branch-protection-internal.js": "./branch-protection-internal.js"
20
21
  },
21
22
  "engines": {
22
23
  "node": ">=20"
23
24
  },
24
25
  "dependencies": {
25
- "@remogram/core": "0.1.0-beta.1"
26
+ "@remogram/core": "0.1.0-beta.10"
26
27
  }
27
28
  }