@remogram/provider-gitlab-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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  fetchJson,
3
+ fetchJsonWithMeta,
3
4
  sanitizeField,
4
5
  sanitizeUrl,
5
6
  assertGitRef,
@@ -7,22 +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,
12
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,
13
59
  } from '@remogram/core';
60
+ import {
61
+ resolveBranchProtection,
62
+ setBranchProtectionImpl,
63
+ } from './branch-protection-internal.js';
14
64
 
15
65
  const PUBLIC_GITLAB_HOST = 'gitlab.com';
16
66
  const PUBLIC_GITLAB_API = 'https://gitlab.com/api/v4';
17
67
  const AUTH_CAPABILITIES = [
18
68
  'repo_status',
19
69
  'ref_compare',
70
+ 'ref_inventory',
71
+ 'cr_inventory',
20
72
  'pr_status',
21
73
  'pr_checks',
22
74
  'merge_plan',
23
75
  'sync_plan',
76
+ 'status_set',
77
+ 'whoami',
24
78
  ];
25
- 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
+ });
26
86
 
27
87
  export function gitlabToken() {
28
88
  return process.env.GITLAB_TOKEN || null;
@@ -115,35 +175,72 @@ export async function gitlabFetch(config, parsed, path, options = {}) {
115
175
  });
116
176
  }
117
177
 
118
- const MAX_CHECK_PAGES = 50;
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;
119
205
  const GITLAB_PAGE_SIZE = 100;
120
206
 
121
207
  export async function gitlabFetchPaginated(config, parsed, path) {
122
- const all = [];
123
- for (let page = 1; page <= MAX_CHECK_PAGES; page += 1) {
124
- const separator = path.includes('?') ? '&' : '?';
125
- const body = await gitlabFetch(
126
- config,
127
- parsed,
128
- `${path}${separator}per_page=${GITLAB_PAGE_SIZE}&page=${page}`,
129
- );
130
- const items = Array.isArray(body) ? body : [];
131
- all.push(...items);
132
- if (items.length < GITLAB_PAGE_SIZE) break;
133
- }
134
- return all;
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
+ });
135
219
  }
136
220
 
137
- export function providerCapabilities() {
221
+ export function providerCapabilities(ctx = {}) {
222
+ const check_sources = ['commit_statuses', 'pipelines'];
138
223
  return {
139
224
  commands: STRUCTURED_COMMANDS,
140
225
  auth_envs: ['GITLAB_TOKEN'],
141
- check_sources: ['commit_statuses', 'pipelines'],
226
+ check_sources,
142
227
  mergeability_confidence: 'derived',
143
228
  host_binding: 'verified_remote_host',
144
229
  pagination: 'supported',
145
- write_support: false,
230
+ write_support: true,
231
+ write_commands: ['status_set'],
146
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
+ }),
147
244
  };
148
245
  }
149
246
 
@@ -174,10 +271,10 @@ export async function refsCompare(ctx, baseRef, headRef) {
174
271
  });
175
272
  }
176
273
  return {
177
- base_ref: sanitizeField(baseRef),
178
- base_sha: baseSha,
179
- head_ref: sanitizeField(headRef),
180
- 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,
181
278
  ...gitAheadBehind(ctx.cwd, baseSha, headSha),
182
279
  };
183
280
  }
@@ -214,10 +311,10 @@ export async function prView(ctx, opts) {
214
311
  url: sanitizeUrl(mr.web_url ?? mr.url),
215
312
  title: sanitizeField(mr.title),
216
313
  state: normalizeMrState(mr.state),
217
- base_ref: sanitizeField(mr.target_branch),
218
- base_sha: sanitizeField(mr.diff_refs?.base_sha),
219
- head_ref: sanitizeField(mr.source_branch),
220
- 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),
221
318
  mergeability: mergeability(mr),
222
319
  };
223
320
  }
@@ -241,6 +338,7 @@ export async function prChecks(ctx, opts) {
241
338
  apiBase(ctx.config, ctx.parsed);
242
339
  requireToken();
243
340
  let sha;
341
+ let requiredContexts = [];
244
342
  if (opts.ref) {
245
343
  assertGitRef(opts.ref, 'ref');
246
344
  sha = gitRevParse(ctx.cwd, opts.ref);
@@ -252,6 +350,11 @@ export async function prChecks(ctx, opts) {
252
350
  } else {
253
351
  const mr = await getMergeRequest(ctx, opts);
254
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
+ }
255
358
  }
256
359
  if (!sha) {
257
360
  throw Object.assign(new Error('No SHA'), {
@@ -259,7 +362,7 @@ export async function prChecks(ctx, opts) {
259
362
  });
260
363
  }
261
364
 
262
- const [statusRecords, pipelineRecords] = await Promise.all([
365
+ const [statusResult, pipelineResult] = await Promise.all([
263
366
  gitlabFetchPaginated(
264
367
  ctx.config,
265
368
  ctx.parsed,
@@ -271,37 +374,352 @@ export async function prChecks(ctx, opts) {
271
374
  `${projectApiPath(ctx.config, 'pipelines')}?sha=${encodeURIComponent(sha)}`,
272
375
  ),
273
376
  ]);
377
+ const statusRecords = statusResult.items;
378
+ const pipelineRecords = pipelineResult.items;
274
379
  const mappedStatuses = statusRecords.map((status) => ({
275
380
  context: sanitizeField(status.name || status.context),
276
381
  state: normalizeStatusState(status.status),
277
382
  description: sanitizeField(status.description || status.status),
383
+ ...(status.target_url ? { target_url: sanitizeField(status.target_url) } : {}),
384
+ sha,
385
+ source: 'commit_status',
278
386
  }));
279
387
  const mappedPipelines = pipelineRecords.map((pipeline) => ({
280
388
  context: sanitizeField(pipeline.name || `pipeline:${pipeline.id}`),
281
389
  state: normalizeStatusState(pipeline.status),
282
390
  description: sanitizeField(pipeline.status),
391
+ sha,
392
+ source: 'pipeline',
283
393
  }));
284
394
  const mapped = [...mappedStatuses, ...mappedPipelines];
285
- 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
+ });
286
403
  }
287
404
 
288
405
  export async function mergePlan(ctx, opts) {
289
- const view = await prView(ctx, opts);
290
- const checks = await prChecks(ctx, { number: view.pr_number });
291
- const blockers = [];
292
- if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
293
- if (view.state !== 'open') blockers.push('pr_not_open');
294
- if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
295
- if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
296
- 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';
297
491
  return {
298
- pr_number: view.pr_number,
299
- mergeability: view.mergeability,
300
- checks_conclusion: checks.check_conclusion,
301
- 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 },
302
501
  };
303
502
  }
304
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
+
305
723
  export async function syncPlan(ctx, remoteName = 'origin') {
306
724
  assertGitRemote(remoteName, 'remote');
307
725
  apiBase(ctx.config, ctx.parsed);
@@ -330,13 +748,221 @@ export async function syncPlan(ctx, remoteName = 'origin') {
330
748
  };
331
749
  }
332
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
+
333
947
  export const provider = {
334
948
  id: 'gitlab-api',
335
949
  providerCapabilities,
950
+ apiReachability,
336
951
  repoStatus,
337
952
  refsCompare,
953
+ refsInventory,
954
+ listOpenPulls,
955
+ crInventory: crInventorySlice,
338
956
  prView,
339
957
  prChecks,
340
958
  mergePlan,
341
959
  syncPlan,
960
+ whoami,
961
+ branchProtection,
962
+ crFiles,
963
+ crComments,
964
+ forgeChanges,
965
+ statusSet,
342
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.1",
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.1"
26
+ "@remogram/core": "0.1.0-beta.10"
26
27
  }
27
28
  }