@remogram/provider-gitlab-api 0.1.0-beta.0 → 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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  fetchJson,
3
+ fetchJsonWithMeta,
3
4
  sanitizeField,
4
5
  sanitizeUrl,
5
6
  assertGitRef,
@@ -7,21 +8,81 @@ import {
7
8
  gitRevParse,
8
9
  gitCurrentBranch,
9
10
  gitAheadBehind,
11
+ refsInventory,
12
+ crInventory,
13
+ buildMergePlanFromProviderFacts,
10
14
  ERROR_CODES,
11
15
  forgeError,
16
+ LIVE_REACHABILITY_TIMEOUT_MS,
17
+ forgeIngestCapabilityFacts,
18
+ forgeWriteFieldCapabilityFacts,
19
+ checkPaginationCapabilityFacts,
20
+ openPullListCapabilityFacts,
21
+ DEFAULT_CHECK_STATUS_PAGE_SIZE,
22
+ MAX_CHECK_STATUS_PAGES,
23
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
24
+ paginateCheckStatusPages,
25
+ paginateOffsetListPages,
26
+ fetchWithIngestPageBackoff,
27
+ fetchPageWithIngestBackoff,
28
+ withPerPageParam,
29
+ apiProviderCommands,
30
+ normalizeCrInventorySort,
31
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
32
+ parseTotalCountHeader,
33
+ isCrInventoryFastPathEligible,
34
+ validateFastPathPageLength,
35
+ isNumberSortFastPathEligible,
36
+ isNumberSortFullCollectRequired,
37
+ resolveListTruncatedWithTrustedTotal,
38
+ orderOpenPullNumbers,
39
+ buildOpenPullListMeta,
40
+ gitlabOpenPullSortQuery,
41
+ appendSortQuery,
42
+ buildProviderIdentityFromGitLabUser,
43
+ buildBranchProtectionFromGitLabProtection,
44
+ buildPrChecksBody,
45
+ buildCrFilesFromGitLabChanges,
46
+ buildCrCommentsBody,
47
+ buildCrCommentsFromGitLabDiscussions,
48
+ parseSinceObservedAt,
49
+ buildForgeChangesFromGiteaPulls,
50
+ buildChecksConclusionObservedEvent,
51
+ appendForgeChangeEvents,
52
+ parseStatusSetArgs,
53
+ buildCommitStatusSetBody,
54
+ idempotencyPacketFields,
55
+ normalizeStatusSetState,
56
+ MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
57
+ statusSetIdempotencyScanCapabilityFacts,
58
+ assertWriteCommandConfigured,
12
59
  } from '@remogram/core';
60
+ import {
61
+ resolveBranchProtection,
62
+ setBranchProtectionImpl,
63
+ } from './branch-protection-internal.js';
13
64
 
14
65
  const PUBLIC_GITLAB_HOST = 'gitlab.com';
15
66
  const PUBLIC_GITLAB_API = 'https://gitlab.com/api/v4';
16
67
  const AUTH_CAPABILITIES = [
17
68
  'repo_status',
18
69
  'ref_compare',
70
+ 'ref_inventory',
71
+ 'cr_inventory',
19
72
  'pr_status',
20
73
  'pr_checks',
21
74
  'merge_plan',
22
75
  'sync_plan',
76
+ 'status_set',
77
+ 'whoami',
23
78
  ];
24
- const STRUCTURED_COMMANDS = AUTH_CAPABILITIES.map((name) => ({ name, implemented: true }));
79
+ const STRUCTURED_COMMANDS = apiProviderCommands({
80
+ branchProtectionImplemented: true,
81
+ crFilesImplemented: true,
82
+ crCommentsImplemented: true,
83
+ forgeChangesImplemented: true,
84
+ statusSetImplemented: true,
85
+ });
25
86
 
26
87
  export function gitlabToken() {
27
88
  return process.env.GITLAB_TOKEN || null;
@@ -114,15 +175,72 @@ export async function gitlabFetch(config, parsed, path, options = {}) {
114
175
  });
115
176
  }
116
177
 
117
- export function providerCapabilities() {
178
+ export async function gitlabFetchWithMeta(config, parsed, path, options = {}) {
179
+ const base = apiBase(config, parsed);
180
+ const token = requireToken();
181
+ const url = `${base}${path}`;
182
+ return fetchJsonWithMeta(url, {
183
+ ...options,
184
+ headers: { ...authHeaders(token), ...(options.headers || {}) },
185
+ });
186
+ }
187
+
188
+ export async function apiReachability(ctx) {
189
+ if (!gitlabToken()) {
190
+ throw Object.assign(new Error('GITLAB_TOKEN not set'), {
191
+ forgeError: forgeError(ERROR_CODES.UNAUTHENTICATED_PROVIDER, 'GITLAB_TOKEN not set'),
192
+ });
193
+ }
194
+ const token = requireToken();
195
+ const url = `${apiBase(ctx.config, ctx.parsed)}${projectApiPath(ctx.config)}`;
196
+ await fetchJson(
197
+ url,
198
+ { headers: authHeaders(token) },
199
+ LIVE_REACHABILITY_TIMEOUT_MS,
200
+ );
201
+ return { repo_accessible: true };
202
+ }
203
+
204
+ const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
205
+ const GITLAB_PAGE_SIZE = 100;
206
+
207
+ export async function gitlabFetchPaginated(config, parsed, path) {
208
+ return paginateCheckStatusPages({
209
+ fetchPage: async ({ page, limit }) => {
210
+ const separator = path.includes('?') ? '&' : '?';
211
+ const body = await gitlabFetch(
212
+ config,
213
+ parsed,
214
+ `${path}${separator}per_page=${limit}&page=${page}`,
215
+ );
216
+ return Array.isArray(body) ? body : [];
217
+ },
218
+ });
219
+ }
220
+
221
+ export function providerCapabilities(ctx = {}) {
222
+ const check_sources = ['commit_statuses', 'pipelines'];
118
223
  return {
119
224
  commands: STRUCTURED_COMMANDS,
120
225
  auth_envs: ['GITLAB_TOKEN'],
121
- check_sources: ['commit_statuses', 'pipelines'],
226
+ check_sources,
122
227
  mergeability_confidence: 'derived',
123
228
  host_binding: 'verified_remote_host',
124
- pagination: 'first_page_only',
125
- write_support: false,
229
+ pagination: 'supported',
230
+ write_support: true,
231
+ write_commands: ['status_set'],
232
+ ...forgeIngestCapabilityFacts(),
233
+ ...forgeWriteFieldCapabilityFacts(ctx.writeFieldPolicy),
234
+ ...statusSetIdempotencyScanCapabilityFacts(),
235
+ ...checkPaginationCapabilityFacts({
236
+ strategy: 'offset_limit',
237
+ pageSizeParam: 'per_page',
238
+ sourceCount: check_sources.length,
239
+ }),
240
+ ...openPullListCapabilityFacts({
241
+ totalCountSource: 'response_header',
242
+ totalCountHeader: 'X-Total',
243
+ }),
126
244
  };
127
245
  }
128
246
 
@@ -143,7 +261,6 @@ export async function repoStatus(ctx) {
143
261
 
144
262
  export async function refsCompare(ctx, baseRef, headRef) {
145
263
  apiBase(ctx.config, ctx.parsed);
146
- requireToken();
147
264
  assertGitRef(baseRef, 'base');
148
265
  assertGitRef(headRef, 'head');
149
266
  const baseSha = gitRevParse(ctx.cwd, baseRef);
@@ -154,10 +271,10 @@ export async function refsCompare(ctx, baseRef, headRef) {
154
271
  });
155
272
  }
156
273
  return {
157
- base_ref: sanitizeField(baseRef),
158
- base_sha: baseSha,
159
- head_ref: sanitizeField(headRef),
160
- head_sha: headSha,
274
+ compare_base_ref: sanitizeField(baseRef),
275
+ compare_base_sha: baseSha,
276
+ compare_head_ref: sanitizeField(headRef),
277
+ compare_head_sha: headSha,
161
278
  ...gitAheadBehind(ctx.cwd, baseSha, headSha),
162
279
  };
163
280
  }
@@ -194,10 +311,10 @@ export async function prView(ctx, opts) {
194
311
  url: sanitizeUrl(mr.web_url ?? mr.url),
195
312
  title: sanitizeField(mr.title),
196
313
  state: normalizeMrState(mr.state),
197
- base_ref: sanitizeField(mr.target_branch),
198
- base_sha: sanitizeField(mr.diff_refs?.base_sha),
199
- head_ref: sanitizeField(mr.source_branch),
200
- head_sha: sanitizeField(mr.sha ?? mr.diff_refs?.head_sha),
314
+ forge_target_branch_ref: sanitizeField(mr.target_branch),
315
+ forge_target_sha: sanitizeField(mr.diff_refs?.base_sha),
316
+ forge_source_branch_ref: sanitizeField(mr.source_branch),
317
+ forge_source_sha: sanitizeField(mr.sha ?? mr.diff_refs?.head_sha),
201
318
  mergeability: mergeability(mr),
202
319
  };
203
320
  }
@@ -221,6 +338,7 @@ export async function prChecks(ctx, opts) {
221
338
  apiBase(ctx.config, ctx.parsed);
222
339
  requireToken();
223
340
  let sha;
341
+ let requiredContexts = [];
224
342
  if (opts.ref) {
225
343
  assertGitRef(opts.ref, 'ref');
226
344
  sha = gitRevParse(ctx.cwd, opts.ref);
@@ -232,6 +350,11 @@ export async function prChecks(ctx, opts) {
232
350
  } else {
233
351
  const mr = await getMergeRequest(ctx, opts);
234
352
  sha = mr.sha ?? mr.diff_refs?.head_sha;
353
+ const targetBranch = mr.target_branch;
354
+ if (targetBranch) {
355
+ const protection = await resolveBranchProtection(ctx, { branchRef: targetBranch });
356
+ requiredContexts = protection.required_status_contexts ?? [];
357
+ }
235
358
  }
236
359
  if (!sha) {
237
360
  throw Object.assign(new Error('No SHA'), {
@@ -239,45 +362,364 @@ export async function prChecks(ctx, opts) {
239
362
  });
240
363
  }
241
364
 
242
- const [statuses, pipelines] = await Promise.all([
243
- gitlabFetch(ctx.config, ctx.parsed, projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses')),
244
- gitlabFetch(
365
+ const [statusResult, pipelineResult] = await Promise.all([
366
+ gitlabFetchPaginated(
367
+ ctx.config,
368
+ ctx.parsed,
369
+ projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses'),
370
+ ),
371
+ gitlabFetchPaginated(
245
372
  ctx.config,
246
373
  ctx.parsed,
247
374
  `${projectApiPath(ctx.config, 'pipelines')}?sha=${encodeURIComponent(sha)}`,
248
375
  ),
249
376
  ]);
250
- const mappedStatuses = (statuses || []).map((status) => ({
377
+ const statusRecords = statusResult.items;
378
+ const pipelineRecords = pipelineResult.items;
379
+ const mappedStatuses = statusRecords.map((status) => ({
251
380
  context: sanitizeField(status.name || status.context),
252
381
  state: normalizeStatusState(status.status),
253
382
  description: sanitizeField(status.description || status.status),
383
+ ...(status.target_url ? { target_url: sanitizeField(status.target_url) } : {}),
384
+ sha,
385
+ source: 'commit_status',
254
386
  }));
255
- const mappedPipelines = (pipelines || []).map((pipeline) => ({
387
+ const mappedPipelines = pipelineRecords.map((pipeline) => ({
256
388
  context: sanitizeField(pipeline.name || `pipeline:${pipeline.id}`),
257
389
  state: normalizeStatusState(pipeline.status),
258
390
  description: sanitizeField(pipeline.status),
391
+ sha,
392
+ source: 'pipeline',
259
393
  }));
260
394
  const mapped = [...mappedStatuses, ...mappedPipelines];
261
- return { head_sha: sha, check_conclusion: summarizeChecks(mapped), statuses: mapped };
395
+ const checks_truncated = statusResult.truncated || pipelineResult.truncated;
396
+ return buildPrChecksBody({
397
+ forge_source_sha: sha,
398
+ check_conclusion: summarizeChecks(mapped),
399
+ checks_truncated,
400
+ statuses: mapped,
401
+ required_contexts: requiredContexts,
402
+ });
262
403
  }
263
404
 
264
405
  export async function mergePlan(ctx, opts) {
265
- const view = await prView(ctx, opts);
266
- const checks = await prChecks(ctx, { number: view.pr_number });
267
- const blockers = [];
268
- if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
269
- if (view.state !== 'open') blockers.push('pr_not_open');
270
- if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
271
- if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
272
- if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
406
+ return buildMergePlanFromProviderFacts(ctx, opts, { prView, prChecks, crFiles });
407
+ }
408
+
409
+ export async function crFiles(ctx, { number }) {
410
+ requireToken();
411
+ if (number == null) {
412
+ throw Object.assign(new Error('--number required'), {
413
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for MR changed paths'),
414
+ });
415
+ }
416
+ const data = await gitlabFetch(
417
+ ctx.config,
418
+ ctx.parsed,
419
+ projectApiPath(ctx.config, 'merge_requests', number, 'changes'),
420
+ );
421
+ return buildCrFilesFromGitLabChanges(number, data?.changes);
422
+ }
423
+
424
+ export async function crComments(ctx, { number }) {
425
+ requireToken();
426
+ if (number == null) {
427
+ throw Object.assign(new Error('--number required'), {
428
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for MR review comments'),
429
+ });
430
+ }
431
+ const path = projectApiPath(ctx.config, 'merge_requests', number, 'discussions');
432
+ const pageSep = path.includes('?') ? '&' : '?';
433
+ let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
434
+ const allDiscussions = [];
435
+ let listTruncated = false;
436
+ let entryCount = 0;
437
+
438
+ for (let page = 1; page <= MAX_CHECK_STATUS_PAGES; page += 1) {
439
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
440
+ async ({ page: pageNum, limit }) => {
441
+ const body = await gitlabFetch(
442
+ ctx.config,
443
+ ctx.parsed,
444
+ `${path}${pageSep}per_page=${limit}&page=${pageNum}`,
445
+ );
446
+ if (!Array.isArray(body)) {
447
+ throw Object.assign(new Error('Provider returned non-array MR discussions list'), {
448
+ forgeError: forgeError(
449
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
450
+ 'Provider returned non-array MR discussions list',
451
+ ),
452
+ });
453
+ }
454
+ return body;
455
+ },
456
+ page,
457
+ activeLimit,
458
+ );
459
+ activeLimit = usedLimit;
460
+ for (const discussion of items) {
461
+ const notes = Array.isArray(discussion?.notes) ? discussion.notes : [];
462
+ for (const note of notes) {
463
+ if (note?.system === true) continue;
464
+ entryCount += 1;
465
+ }
466
+ }
467
+ allDiscussions.push(...items);
468
+ if (items.length < usedLimit) break;
469
+ if (page === MAX_CHECK_STATUS_PAGES) {
470
+ listTruncated = true;
471
+ }
472
+ }
473
+
474
+ const body = buildCrCommentsFromGitLabDiscussions(number, allDiscussions);
475
+ if (listTruncated) {
476
+ return buildCrCommentsBody({
477
+ pr_number: body.pr_number,
478
+ comments: body.comments,
479
+ comments_truncated: true,
480
+ comment_count: entryCount,
481
+ });
482
+ }
483
+ return body;
484
+ }
485
+
486
+ function gitlabMergeRequestAsPull(mr) {
487
+ if (mr == null || mr.iid == null) return null;
488
+ let state = 'unknown';
489
+ if (mr.state === 'opened') state = 'open';
490
+ else if (mr.state === 'merged' || mr.state === 'closed') state = 'closed';
273
491
  return {
274
- pr_number: view.pr_number,
275
- mergeability: view.mergeability,
276
- checks_conclusion: checks.check_conclusion,
277
- blockers,
492
+ number: mr.iid,
493
+ title: mr.title,
494
+ html_url: mr.web_url,
495
+ state,
496
+ created_at: mr.created_at,
497
+ updated_at: mr.updated_at,
498
+ closed_at: mr.closed_at,
499
+ merged_at: mr.merged_at,
500
+ head: { sha: mr.sha ?? mr.diff_refs?.head_sha ?? null },
278
501
  };
279
502
  }
280
503
 
504
+ export async function forgeChanges(ctx, { since }) {
505
+ requireToken();
506
+ const sinceIso = parseSinceObservedAt(since);
507
+ const path = `${projectApiPath(ctx.config, 'merge_requests')}?state=all&order_by=updated_at&sort=desc`;
508
+ const pageSep = '&';
509
+ let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
510
+ const allMrs = [];
511
+ let listTruncated = false;
512
+
513
+ for (let page = 1; page <= MAX_CHECK_STATUS_PAGES; page += 1) {
514
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
515
+ async ({ page: pageNum, limit }) => {
516
+ const body = await gitlabFetch(
517
+ ctx.config,
518
+ ctx.parsed,
519
+ `${path}${pageSep}per_page=${limit}&page=${pageNum}`,
520
+ );
521
+ if (!Array.isArray(body)) {
522
+ throw Object.assign(new Error('Provider returned non-array merge request list'), {
523
+ forgeError: forgeError(
524
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
525
+ 'Provider returned non-array merge request list',
526
+ ),
527
+ });
528
+ }
529
+ return body;
530
+ },
531
+ page,
532
+ activeLimit,
533
+ );
534
+ activeLimit = usedLimit;
535
+ allMrs.push(...items);
536
+ if (items.length < usedLimit) break;
537
+ if (page === MAX_CHECK_STATUS_PAGES) {
538
+ listTruncated = true;
539
+ }
540
+ }
541
+
542
+ const pulls = allMrs.map(gitlabMergeRequestAsPull).filter(Boolean);
543
+ let body = buildForgeChangesFromGiteaPulls(sinceIso, pulls, { listTruncated });
544
+ const checkNumbers = new Set();
545
+ for (const event of body.events) {
546
+ if (event.kind === 'pr_opened' || event.kind === 'head_sha_moved') {
547
+ checkNumbers.add(event.pr_number);
548
+ }
549
+ }
550
+
551
+ const checkEvents = [];
552
+ for (const number of checkNumbers) {
553
+ const checks = await prChecks(ctx, { number });
554
+ checkEvents.push(buildChecksConclusionObservedEvent(number, checks));
555
+ }
556
+
557
+ if (checkEvents.length > 0) {
558
+ body = appendForgeChangeEvents(body, checkEvents, { listTruncated });
559
+ }
560
+
561
+ return body;
562
+ }
563
+
564
+ const GITLAB_OPEN_PULL_COMPLIANT_MAX =
565
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
566
+
567
+ async function probeGitlabOpenPullPageOne(ctx, retainMax, sliceSort) {
568
+ const maxTrusted = GITLAB_OPEN_PULL_COMPLIANT_MAX * 2;
569
+ let path = `${projectApiPath(ctx.config, 'merge_requests')}?state=opened`;
570
+ path = appendSortQuery(path, gitlabOpenPullSortQuery(sliceSort));
571
+ const separator = path.includes('?') ? '&' : '?';
572
+ const requestLimit = Math.min(retainMax, GITLAB_PAGE_SIZE);
573
+ try {
574
+ const { body, headers } = await gitlabFetchWithMeta(
575
+ ctx.config,
576
+ ctx.parsed,
577
+ `${path}${separator}per_page=${requestLimit}&page=1`,
578
+ );
579
+ if (!Array.isArray(body)) return null;
580
+ const totalCount = parseTotalCountHeader(headers, 'X-Total', { maxTrusted });
581
+ if (totalCount == null) return null;
582
+ const listTruncated = totalCount > GITLAB_OPEN_PULL_COMPLIANT_MAX;
583
+ return { body, totalCount, listTruncated, requestLimit };
584
+ } catch {
585
+ return null;
586
+ }
587
+ }
588
+
589
+ function buildGitlabOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, listTruncated) {
590
+ let numbers = orderOpenPullNumbers(body, (mr) => mr?.iid, sliceSort);
591
+ if (numbers.length > retainMax) numbers = numbers.slice(0, retainMax);
592
+ return buildOpenPullListMeta({
593
+ totalCount,
594
+ numbers,
595
+ listTruncated,
596
+ sliceSort,
597
+ });
598
+ }
599
+
600
+ function gitlabProbePaginationOpts(probe, extra = {}) {
601
+ const { body, totalCount, requestLimit } = probe;
602
+ return {
603
+ trustedTotalCount: totalCount,
604
+ seededFirstPage: { items: body, usedLimit: requestLimit },
605
+ ...extra,
606
+ };
607
+ }
608
+
609
+ async function paginateGitlabOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
610
+ const {
611
+ trustedTotalCount = null,
612
+ numberSortFullCollect = false,
613
+ seededFirstPage = null,
614
+ startPage = 1,
615
+ maxPages = MAX_CHECK_STATUS_PAGES,
616
+ suppressFinalPageProbe = false,
617
+ } = paginationOpts;
618
+ const listLimit =
619
+ opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
620
+ ? Number(opts.limit)
621
+ : null;
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
+ let path = `${projectApiPath(ctx.config, 'merge_requests')}?state=opened`;
630
+ path = appendSortQuery(path, gitlabOpenPullSortQuery(sliceSort));
631
+ const separator = path.includes('?') ? '&' : '?';
632
+ const effectiveRetainMax = numberSortFullCollect ? null : retainMax;
633
+ const {
634
+ items: all,
635
+ list_truncated: listTruncated,
636
+ entry_count: entryCount,
637
+ walked_count: walkedCount,
638
+ } = await paginateOffsetListPages({
639
+ pageSize: GITLAB_PAGE_SIZE,
640
+ listLimit,
641
+ retainMax: effectiveRetainMax,
642
+ trustedEntryCount: trustedTotalCount,
643
+ seededFirstPage,
644
+ startPage,
645
+ maxPages,
646
+ suppressFinalPageProbe,
647
+ ...(listLimit != null ? { maxPagesTruncatesWithLimit: true } : {}),
648
+ fetchPage: async ({ page, limit }) => {
649
+ const body = await gitlabFetch(
650
+ ctx.config,
651
+ ctx.parsed,
652
+ `${path}${separator}per_page=${limit}&page=${page}`,
653
+ );
654
+ return Array.isArray(body) ? body : [];
655
+ },
656
+ });
657
+ let numbers = orderOpenPullNumbers(all, (mr) => mr?.iid, sliceSort);
658
+ const outputCap = listLimit ?? retainMax;
659
+ if (outputCap != null && numbers.length > outputCap) {
660
+ numbers = numbers.slice(0, outputCap);
661
+ }
662
+ return {
663
+ numbers,
664
+ list_truncated: resolveListTruncatedWithTrustedTotal({
665
+ listTruncated,
666
+ trustedTotalCount,
667
+ walkedCount,
668
+ fullCollect: numberSortFullCollect,
669
+ }),
670
+ ...(entryCount != null ? { entry_count: entryCount } : {}),
671
+ slice_sort: sliceSort,
672
+ };
673
+ }
674
+
675
+ export async function listOpenPullsWithMeta(ctx, opts = {}) {
676
+ apiBase(ctx.config, ctx.parsed);
677
+ requireToken();
678
+ const sliceSort = normalizeCrInventorySort(opts.sort ?? DEFAULT_CR_INVENTORY_SLICE_SORT);
679
+ if (!isCrInventoryFastPathEligible(opts)) {
680
+ return paginateGitlabOpenPullList(ctx, opts, sliceSort);
681
+ }
682
+
683
+ const retainMax = Number(opts.retain_max);
684
+ const probe = await probeGitlabOpenPullPageOne(ctx, retainMax, sliceSort);
685
+ if (!probe) {
686
+ return paginateGitlabOpenPullList(ctx, opts, sliceSort);
687
+ }
688
+
689
+ const { body, totalCount, listTruncated, requestLimit } = probe;
690
+
691
+ if (listTruncated) {
692
+ if (body.length === 0) {
693
+ return paginateGitlabOpenPullList(ctx, opts, sliceSort, gitlabProbePaginationOpts(probe));
694
+ }
695
+ return buildGitlabOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, true);
696
+ }
697
+
698
+ if (
699
+ isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) &&
700
+ validateFastPathPageLength(totalCount, requestLimit, body.length)
701
+ ) {
702
+ return buildGitlabOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
703
+ }
704
+
705
+ const numberSortFullCollect = isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort);
706
+ return paginateGitlabOpenPullList(
707
+ ctx,
708
+ opts,
709
+ sliceSort,
710
+ gitlabProbePaginationOpts(probe, { numberSortFullCollect }),
711
+ );
712
+ }
713
+
714
+ export async function listOpenPulls(ctx, opts = {}) {
715
+ const meta = await listOpenPullsWithMeta(ctx, opts);
716
+ return meta.numbers;
717
+ }
718
+
719
+ export async function crInventorySlice(ctx, opts = {}) {
720
+ return crInventory(ctx, { listOpenPulls, listOpenPullsWithMeta, prView, prChecks }, opts);
721
+ }
722
+
281
723
  export async function syncPlan(ctx, remoteName = 'origin') {
282
724
  assertGitRemote(remoteName, 'remote');
283
725
  apiBase(ctx.config, ctx.parsed);
@@ -306,13 +748,221 @@ export async function syncPlan(ctx, remoteName = 'origin') {
306
748
  };
307
749
  }
308
750
 
751
+ async function fetchGitLabPatSelf(ctx) {
752
+ try {
753
+ return await gitlabFetch(ctx.config, ctx.parsed, '/personal_access_tokens/self');
754
+ } catch {
755
+ return null;
756
+ }
757
+ }
758
+
759
+ export async function whoami(ctx) {
760
+ requireToken();
761
+ const user = await gitlabFetch(ctx.config, ctx.parsed, '/user');
762
+ const patSelf = await fetchGitLabPatSelf(ctx);
763
+ return buildProviderIdentityFromGitLabUser(user, patSelf);
764
+ }
765
+
766
+ function approvalRulesForBranch(rules, branchRef) {
767
+ if (!Array.isArray(rules)) return [];
768
+ return rules.filter((rule) => {
769
+ const branches = rule?.protected_branches;
770
+ if (!Array.isArray(branches)) return false;
771
+ return branches.some((branch) => branch?.name === branchRef);
772
+ });
773
+ }
774
+
775
+ export async function branchProtection(ctx, { branchRef }) {
776
+ assertGitRef(branchRef, '--branch-ref');
777
+ requireToken();
778
+ let protectedBranch = null;
779
+ try {
780
+ protectedBranch = await gitlabFetch(
781
+ ctx.config,
782
+ ctx.parsed,
783
+ projectApiPath(ctx.config, 'protected_branches', branchRef),
784
+ );
785
+ } catch (err) {
786
+ if (err?.status !== 404) throw err;
787
+ }
788
+ let approvalRules = [];
789
+ if (protectedBranch != null) {
790
+ try {
791
+ const allRules = await gitlabFetch(
792
+ ctx.config,
793
+ ctx.parsed,
794
+ projectApiPath(ctx.config, 'approval_rules'),
795
+ );
796
+ approvalRules = approvalRulesForBranch(allRules, branchRef);
797
+ } catch {
798
+ approvalRules = [];
799
+ }
800
+ }
801
+ return buildBranchProtectionFromGitLabProtection(branchRef, { protectedBranch, approvalRules });
802
+ }
803
+
804
+ function gitlabStatusRecordOrder(a, b) {
805
+ const aUpdated = Date.parse(a.updated_at ?? a.created_at ?? '') || 0;
806
+ const bUpdated = Date.parse(b.updated_at ?? b.created_at ?? '') || 0;
807
+ if (aUpdated !== bUpdated) return aUpdated - bUpdated;
808
+ const aId = Number(a.id) || 0;
809
+ const bId = Number(b.id) || 0;
810
+ return aId - bId;
811
+ }
812
+
813
+ function gitlabStatusAsRemogramState(status) {
814
+ const normalized = String(status ?? '').toLowerCase();
815
+ if (normalized === 'failed' || normalized === 'canceled') return 'failure';
816
+ if (
817
+ normalized === 'running'
818
+ || normalized === 'created'
819
+ || normalized === 'waiting_for_resource'
820
+ || normalized === 'preparing'
821
+ ) {
822
+ return 'pending';
823
+ }
824
+ return normalizeStatusSetState(normalized);
825
+ }
826
+
827
+ function remogramStateToGitlabPostState(state) {
828
+ if (state === 'failure' || state === 'error') return 'failed';
829
+ return state;
830
+ }
831
+
832
+ function statusSetIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
833
+ return forgeError(
834
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
835
+ 'Cannot prove no commit status exists for sha+context within scan limit; retry or set manually',
836
+ null,
837
+ {
838
+ idempotency_scan: {
839
+ pages: pagesScanned,
840
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
841
+ page_size: pageSizeUsed,
842
+ },
843
+ },
844
+ );
845
+ }
846
+
847
+ /** Paginated commit-status scan for idempotent status set; fail-closed when scan cap prevents proof of absence. */
848
+ export async function findCommitStatusByContext(ctx, sha, context) {
849
+ requireToken();
850
+ const path = projectApiPath(ctx.config, 'repository', 'commits', sha, 'statuses');
851
+ const pageSep = path.includes('?') ? '&' : '?';
852
+ let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
853
+ let bestMatch = null;
854
+
855
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
856
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
857
+ async ({ page: pageNum, limit }) => {
858
+ const body = await gitlabFetch(
859
+ ctx.config,
860
+ ctx.parsed,
861
+ `${path}${pageSep}per_page=${limit}&page=${pageNum}`,
862
+ );
863
+ if (!Array.isArray(body)) {
864
+ throw Object.assign(new Error('Provider returned non-array commit status list'), {
865
+ forgeError: forgeError(
866
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
867
+ 'Provider returned non-array commit status list',
868
+ ),
869
+ });
870
+ }
871
+ return body;
872
+ },
873
+ page,
874
+ activeLimit,
875
+ );
876
+ activeLimit = usedLimit;
877
+
878
+ for (const record of items) {
879
+ if (record?.name !== context) continue;
880
+ if (!bestMatch || gitlabStatusRecordOrder(record, bestMatch) > 0) {
881
+ bestMatch = record;
882
+ }
883
+ }
884
+
885
+ if (items.length < usedLimit) return bestMatch;
886
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
887
+ throw Object.assign(new Error('Commit status idempotency scan incomplete'), {
888
+ forgeError: statusSetIdempotencyScanIncompleteError(page, usedLimit),
889
+ });
890
+ }
891
+ }
892
+ return bestMatch;
893
+ }
894
+
895
+ export async function statusSet(ctx, args) {
896
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'status_set');
897
+ const { idempotencyFingerprint = null, ...rest } = args;
898
+ const parsed = parseStatusSetArgs(rest);
899
+ const existing = await findCommitStatusByContext(ctx, parsed.sha, parsed.context);
900
+ if (existing) {
901
+ const requestedGitlabState = remogramStateToGitlabPostState(parsed.state);
902
+ const existingGitlabState = String(existing.status ?? existing.state ?? '').toLowerCase();
903
+ if (existingGitlabState === requestedGitlabState) {
904
+ const remogramState = gitlabStatusAsRemogramState(existing.status ?? existing.state);
905
+ return buildCommitStatusSetBody(
906
+ { ...existing, status: remogramState },
907
+ parsed,
908
+ {
909
+ reusedExisting: true,
910
+ idempotencyFields: idempotencyFingerprint
911
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
912
+ : null,
913
+ },
914
+ );
915
+ }
916
+ }
917
+ const payload = {
918
+ state: remogramStateToGitlabPostState(parsed.state),
919
+ name: parsed.context,
920
+ };
921
+ if (parsed.description != null) payload.description = parsed.description;
922
+ if (parsed.target_url != null) payload.target_url = parsed.target_url;
923
+ const response = await gitlabFetch(
924
+ ctx.config,
925
+ ctx.parsed,
926
+ projectApiPath(ctx.config, 'statuses', parsed.sha),
927
+ {
928
+ method: 'POST',
929
+ headers: { 'Content-Type': 'application/json' },
930
+ body: JSON.stringify(payload),
931
+ },
932
+ );
933
+ return buildCommitStatusSetBody(
934
+ {
935
+ ...response,
936
+ status: gitlabStatusAsRemogramState(response?.status ?? response?.state ?? parsed.state),
937
+ },
938
+ parsed,
939
+ {
940
+ idempotencyFields: idempotencyFingerprint
941
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
942
+ : null,
943
+ },
944
+ );
945
+ }
946
+
309
947
  export const provider = {
310
948
  id: 'gitlab-api',
311
949
  providerCapabilities,
950
+ apiReachability,
312
951
  repoStatus,
313
952
  refsCompare,
953
+ refsInventory,
954
+ listOpenPulls,
955
+ crInventory: crInventorySlice,
314
956
  prView,
315
957
  prChecks,
316
958
  mergePlan,
317
959
  syncPlan,
960
+ whoami,
961
+ branchProtection,
962
+ crFiles,
963
+ crComments,
964
+ forgeChanges,
965
+ statusSet,
318
966
  };
967
+
968
+ setBranchProtectionImpl(branchProtection);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/provider-gitlab-api",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.10",
4
4
  "description": "GitLab 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.0"
26
+ "@remogram/core": "0.1.0-beta.10"
26
27
  }
27
28
  }