@remogram/provider-gitea-api 0.1.0-beta.3 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +320 -29
  2. package/package.json +2 -2
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,
@@ -9,19 +10,39 @@ import {
9
10
  gitAheadBehind,
10
11
  refsInventory,
11
12
  crInventory,
12
- mergeBlockersFromFacts,
13
+ buildMergePlanBodyFromFacts,
14
+ buildChangeRequestOpenedBody,
13
15
  ERROR_CODES,
14
16
  forgeError,
15
17
  forgeIngestCapabilityFacts,
16
18
  checkPaginationCapabilityFacts,
19
+ idempotencyScanCapabilityFacts,
20
+ openPullListCapabilityFacts,
17
21
  DEFAULT_CHECK_STATUS_PAGE_SIZE,
18
22
  MAX_CHECK_STATUS_PAGES,
23
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
24
+ MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
19
25
  paginateCheckStatusPages,
20
26
  paginateOffsetListPages,
21
27
  fetchWithIngestPageBackoff,
22
28
  fetchPageWithIngestBackoff,
23
29
  withPerPageParam,
24
30
  apiProviderCommands,
31
+ normalizeCrInventorySort,
32
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
33
+ parseTotalCountHeader,
34
+ isCrInventoryFastPathEligible,
35
+ validateFastPathPageLength,
36
+ isNumberSortFastPathEligible,
37
+ isRecentCreatedFastPathEligible,
38
+ giteaRecentCreatedTailPage,
39
+ isNumberSortFullCollectRequired,
40
+ resolveListTruncatedWithTrustedTotal,
41
+ prepareGiteaOpenPullPageItems,
42
+ orderOpenPullNumbers,
43
+ buildOpenPullListMeta,
44
+ giteaOpenPullSortQuery,
45
+ appendSortQuery,
25
46
  } from '@remogram/core';
26
47
  const PUBLIC_GITEA_HOST = 'gitea.com';
27
48
  const PUBLIC_GITEA_API = 'https://gitea.com/api/v1';
@@ -34,9 +55,10 @@ const AUTH_CAPABILITIES = [
34
55
  'pr_checks',
35
56
  'merge_plan',
36
57
  'sync_plan',
58
+ 'cr_open',
37
59
  ];
38
60
 
39
- const STRUCTURED_COMMANDS = apiProviderCommands();
61
+ const STRUCTURED_COMMANDS = apiProviderCommands({ writeCommandsImplemented: true });
40
62
 
41
63
  export function giteaToken() {
42
64
  return process.env.GITEA_TOKEN || null;
@@ -132,8 +154,32 @@ export async function giteaFetch(config, parsed, path, options = {}) {
132
154
  });
133
155
  }
134
156
 
157
+ export async function giteaFetchWithMeta(config, parsed, path, options = {}) {
158
+ const token = requireToken();
159
+ const url = `${apiBase(config, parsed)}${path}`;
160
+ return fetchJsonWithMeta(url, {
161
+ ...options,
162
+ headers: { ...authHeaders(token), ...(options.headers || {}) },
163
+ });
164
+ }
165
+
135
166
  const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
136
- const GITEA_PAGE_SIZE = 100;
167
+ const GITEA_PAGE_SIZE = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE;
168
+
169
+ function idempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
170
+ return forgeError(
171
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
172
+ 'Cannot prove no open pull exists for head+base within scan limit; use cr inventory or open manually',
173
+ null,
174
+ {
175
+ idempotency_scan: {
176
+ pages: pagesScanned,
177
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
178
+ page_size: pageSizeUsed,
179
+ },
180
+ },
181
+ );
182
+ }
137
183
 
138
184
  export async function giteaFetchPaginated(config, parsed, path) {
139
185
  return paginateCheckStatusPages({
@@ -222,13 +268,27 @@ export function providerCapabilities() {
222
268
  mergeability_confidence: 'direct',
223
269
  host_binding: 'verified_remote_host',
224
270
  pagination: 'supported',
225
- write_support: false,
271
+ write_support: true,
272
+ write_commands: ['cr_open'],
226
273
  ...forgeIngestCapabilityFacts(),
227
274
  ...checkPaginationCapabilityFacts({
228
275
  strategy: 'offset_limit',
229
276
  pageSizeParam: 'limit',
230
277
  sourceCount: check_sources.length,
231
278
  }),
279
+ ...idempotencyScanCapabilityFacts(),
280
+ ...openPullListCapabilityFacts({
281
+ totalCountSource: 'response_header',
282
+ totalCountHeader: 'X-Total-Count',
283
+ sliceSortNotes: {
284
+ recent_created:
285
+ 'sort=oldest; fetches tail page when total exceeds limit; page reversed for newest-first',
286
+ number_asc:
287
+ 'full-list collect within compliant_max when total exceeds limit, then client sort',
288
+ number_desc:
289
+ 'full-list collect within compliant_max when total exceeds limit, then client sort',
290
+ },
291
+ }),
232
292
  };
233
293
  }
234
294
 
@@ -261,37 +321,273 @@ export async function getPull(ctx, { number }) {
261
321
  return giteaFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'pulls', number));
262
322
  }
263
323
 
264
- export async function listOpenPullsWithMeta(ctx, opts = {}) {
324
+ /** Paginated open-pull scan for idempotent cr open; fail-closed when scan cap prevents proof of absence. */
325
+ export async function findOpenPullByHeadBase(ctx, head, base) {
265
326
  requireToken();
327
+ const path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
328
+ const pageSep = path.includes('?') ? '&' : '?';
329
+ let activeLimit = GITEA_PAGE_SIZE;
330
+
331
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
332
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
333
+ async ({ page: pageNum, limit }) => {
334
+ const body = await giteaFetch(
335
+ ctx.config,
336
+ ctx.parsed,
337
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
338
+ );
339
+ if (!Array.isArray(body)) {
340
+ throw Object.assign(new Error('Provider returned non-array open pull list'), {
341
+ forgeError: forgeError(
342
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
343
+ 'Provider returned non-array open pull list',
344
+ ),
345
+ });
346
+ }
347
+ return body;
348
+ },
349
+ page,
350
+ activeLimit,
351
+ );
352
+ activeLimit = usedLimit;
353
+
354
+ const match =
355
+ items.find(
356
+ (pr) =>
357
+ String(pr?.state ?? '').toLowerCase() === 'open' &&
358
+ pr?.head?.ref === head &&
359
+ pr?.base?.ref === base,
360
+ ) ?? null;
361
+ if (match) return match;
362
+ if (items.length < usedLimit) return null;
363
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
364
+ throw Object.assign(new Error('Open pull idempotency scan incomplete'), {
365
+ forgeError: idempotencyScanIncompleteError(page, usedLimit),
366
+ });
367
+ }
368
+ }
369
+ return null;
370
+ }
371
+
372
+ export async function crOpen(ctx, { head, base, title, body: prBody }) {
373
+ assertGitRef(head, 'head');
374
+ assertGitRef(base, 'base');
375
+ if (!title || typeof title !== 'string' || !title.trim()) {
376
+ throw Object.assign(new Error('--title required'), {
377
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--title required for cr open'),
378
+ });
379
+ }
380
+ const payload = {
381
+ title: sanitizeField(title),
382
+ head: sanitizeField(head),
383
+ base: sanitizeField(base),
384
+ };
385
+ if (prBody != null && String(prBody).trim() !== '') {
386
+ payload.body = sanitizeField(String(prBody));
387
+ }
388
+ const existing = await findOpenPullByHeadBase(ctx, payload.head, payload.base);
389
+ if (existing) {
390
+ return buildChangeRequestOpenedBody(existing, { head, base, title }, { reusedExisting: true });
391
+ }
392
+ const pull = await giteaFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'pulls'), {
393
+ method: 'POST',
394
+ headers: { 'Content-Type': 'application/json' },
395
+ body: JSON.stringify(payload),
396
+ });
397
+ return buildChangeRequestOpenedBody(pull, { head, base, title });
398
+ }
399
+
400
+ const GITEA_OPEN_PULL_COMPLIANT_MAX =
401
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
402
+
403
+ async function probeGiteaOpenPullPageOne(ctx, retainMax, sliceSort) {
404
+ const maxTrusted = GITEA_OPEN_PULL_COMPLIANT_MAX * 2;
405
+ let path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
406
+ path = appendSortQuery(path, giteaOpenPullSortQuery(sliceSort));
407
+ const pageSep = path.includes('?') ? '&' : '?';
408
+ const requestLimit = Math.min(retainMax, GITEA_PAGE_SIZE);
409
+ try {
410
+ const { body, headers } = await giteaFetchWithMeta(
411
+ ctx.config,
412
+ ctx.parsed,
413
+ `${path}${pageSep}limit=${requestLimit}&page=1`,
414
+ );
415
+ if (!Array.isArray(body)) return null;
416
+ const totalCount = parseTotalCountHeader(headers, 'X-Total-Count', { maxTrusted });
417
+ if (totalCount == null) return null;
418
+ const listTruncated = totalCount > GITEA_OPEN_PULL_COMPLIANT_MAX;
419
+ return { body, totalCount, listTruncated, requestLimit };
420
+ } catch {
421
+ return null;
422
+ }
423
+ }
424
+
425
+ function buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, listTruncated) {
426
+ const pageItems = prepareGiteaOpenPullPageItems(body, sliceSort);
427
+ let numbers = orderOpenPullNumbers(pageItems, (pr) => pr?.number, sliceSort);
428
+ if (numbers.length > retainMax) numbers = numbers.slice(0, retainMax);
429
+ return buildOpenPullListMeta({
430
+ totalCount,
431
+ numbers,
432
+ listTruncated,
433
+ sliceSort,
434
+ });
435
+ }
436
+
437
+ async function fetchGiteaRecentCreatedTailSlice(ctx, retainMax, sliceSort, totalCount) {
438
+ const tailPage = giteaRecentCreatedTailPage(totalCount, GITEA_PAGE_SIZE);
439
+ let path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
440
+ path = appendSortQuery(path, giteaOpenPullSortQuery(sliceSort));
441
+ const pageSep = path.includes('?') ? '&' : '?';
442
+ let body;
443
+ try {
444
+ body = await giteaFetch(
445
+ ctx.config,
446
+ ctx.parsed,
447
+ `${path}${pageSep}limit=${GITEA_PAGE_SIZE}&page=${tailPage}`,
448
+ );
449
+ } catch {
450
+ return null;
451
+ }
452
+ if (!Array.isArray(body) || body.length === 0) return null;
453
+ return buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
454
+ }
455
+
456
+ function giteaProbePaginationOpts(probe, extra = {}) {
457
+ const { body, totalCount, requestLimit } = probe;
458
+ return {
459
+ trustedTotalCount: totalCount,
460
+ seededFirstPage: { items: body, usedLimit: requestLimit },
461
+ ...extra,
462
+ };
463
+ }
464
+
465
+ async function paginateGiteaOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
466
+ const {
467
+ trustedTotalCount = null,
468
+ numberSortFullCollect = false,
469
+ seededFirstPage = null,
470
+ startPage = 1,
471
+ maxPages = MAX_CHECK_STATUS_PAGES,
472
+ suppressFinalPageProbe = false,
473
+ } = paginationOpts;
266
474
  const listLimit =
267
475
  opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
268
476
  ? Number(opts.limit)
269
477
  : null;
478
+ const retainMax =
479
+ listLimit == null &&
480
+ opts.retain_max != null &&
481
+ Number.isInteger(Number(opts.retain_max)) &&
482
+ Number(opts.retain_max) > 0
483
+ ? Number(opts.retain_max)
484
+ : null;
270
485
  const pageSize =
271
486
  listLimit != null ? Math.min(listLimit, GITEA_PAGE_SIZE) : GITEA_PAGE_SIZE;
272
- const path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
487
+ let path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
488
+ path = appendSortQuery(path, giteaOpenPullSortQuery(sliceSort));
273
489
  const pageSep = path.includes('?') ? '&' : '?';
274
- const { items: all, list_truncated: listTruncated } = await paginateOffsetListPages({
490
+ const effectiveRetainMax = numberSortFullCollect ? null : retainMax;
491
+ const {
492
+ items: all,
493
+ list_truncated: listTruncated,
494
+ entry_count: entryCount,
495
+ walked_count: walkedCount,
496
+ } = await paginateOffsetListPages({
275
497
  pageSize,
276
498
  listLimit,
499
+ retainMax: effectiveRetainMax,
500
+ trustedEntryCount: trustedTotalCount,
501
+ seededFirstPage,
502
+ startPage,
503
+ maxPages,
504
+ suppressFinalPageProbe,
277
505
  ...(listLimit != null && pageSize < listLimit ? { maxPagesTruncatesWithLimit: true } : {}),
278
506
  fetchPage: async ({ page, limit }) => {
279
- const body = await giteaFetch(
280
- ctx.config,
281
- ctx.parsed,
282
- `${path}${pageSep}limit=${limit}&page=${page}`,
283
- );
284
- return Array.isArray(body) ? body : [];
285
- },
286
- });
287
- let numbers = all
288
- .map((pr) => pr.number)
289
- .filter((number) => Number.isInteger(number))
290
- .sort((a, b) => a - b);
291
- if (listLimit != null && numbers.length > listLimit) {
292
- numbers = numbers.slice(0, listLimit);
507
+ const body = await giteaFetch(
508
+ ctx.config,
509
+ ctx.parsed,
510
+ `${path}${pageSep}limit=${limit}&page=${page}`,
511
+ );
512
+ return Array.isArray(body) ? body : [];
513
+ },
514
+ });
515
+ let numbers = orderOpenPullNumbers(
516
+ prepareGiteaOpenPullPageItems(all, sliceSort),
517
+ (pr) => pr?.number,
518
+ sliceSort,
519
+ );
520
+ const outputCap = listLimit ?? retainMax;
521
+ if (outputCap != null && numbers.length > outputCap) {
522
+ numbers = numbers.slice(0, outputCap);
523
+ }
524
+ return {
525
+ numbers,
526
+ list_truncated: resolveListTruncatedWithTrustedTotal({
527
+ listTruncated,
528
+ trustedTotalCount,
529
+ walkedCount,
530
+ fullCollect: numberSortFullCollect,
531
+ }),
532
+ ...(entryCount != null ? { entry_count: entryCount } : {}),
533
+ slice_sort: sliceSort,
534
+ };
535
+ }
536
+
537
+ export async function listOpenPullsWithMeta(ctx, opts = {}) {
538
+ requireToken();
539
+ const sliceSort = normalizeCrInventorySort(opts.sort ?? DEFAULT_CR_INVENTORY_SLICE_SORT);
540
+ if (!isCrInventoryFastPathEligible(opts)) {
541
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort);
542
+ }
543
+
544
+ const retainMax = Number(opts.retain_max);
545
+ const probe = await probeGiteaOpenPullPageOne(ctx, retainMax, sliceSort);
546
+ if (!probe) {
547
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort);
548
+ }
549
+
550
+ const { body, totalCount, listTruncated, requestLimit } = probe;
551
+
552
+ if (listTruncated) {
553
+ if (body.length === 0) {
554
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort, giteaProbePaginationOpts(probe));
555
+ }
556
+ return buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, true);
293
557
  }
294
- return { numbers, list_truncated: listTruncated };
558
+
559
+ if (
560
+ sliceSort === 'recent_created' &&
561
+ !isRecentCreatedFastPathEligible(totalCount, retainMax, sliceSort, 'gitea-api')
562
+ ) {
563
+ const tail = await fetchGiteaRecentCreatedTailSlice(ctx, retainMax, sliceSort, totalCount);
564
+ if (tail) return tail;
565
+ const tailRetry = await fetchGiteaRecentCreatedTailSlice(ctx, retainMax, sliceSort, totalCount);
566
+ if (tailRetry) return tailRetry;
567
+ const tailPage = giteaRecentCreatedTailPage(totalCount, GITEA_PAGE_SIZE);
568
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort, {
569
+ trustedTotalCount: totalCount,
570
+ startPage: tailPage,
571
+ maxPages: tailPage,
572
+ suppressFinalPageProbe: true,
573
+ });
574
+ }
575
+
576
+ if (
577
+ isRecentCreatedFastPathEligible(totalCount, retainMax, sliceSort, 'gitea-api') &&
578
+ isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) &&
579
+ validateFastPathPageLength(totalCount, requestLimit, body.length)
580
+ ) {
581
+ return buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
582
+ }
583
+
584
+ const numberSortFullCollect = isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort);
585
+ return paginateGiteaOpenPullList(
586
+ ctx,
587
+ opts,
588
+ sliceSort,
589
+ giteaProbePaginationOpts(probe, { numberSortFullCollect }),
590
+ );
295
591
  }
296
592
 
297
593
  export async function listOpenPulls(ctx, opts = {}) {
@@ -370,13 +666,7 @@ export function summarizeChecks(statuses) {
370
666
  export async function mergePlan(ctx, opts) {
371
667
  const view = await prView(ctx, opts);
372
668
  const checks = await prChecks(ctx, { number: view.pr_number });
373
- const blockers = mergeBlockersFromFacts(view, checks);
374
- return {
375
- pr_number: view.pr_number,
376
- mergeability: view.mergeability,
377
- checks_conclusion: checks.check_conclusion,
378
- blockers,
379
- };
669
+ return buildMergePlanBodyFromFacts(ctx, view, checks, opts);
380
670
  }
381
671
 
382
672
  export async function syncPlan(ctx, remoteName = 'origin') {
@@ -418,4 +708,5 @@ export const provider = {
418
708
  prChecks,
419
709
  mergePlan,
420
710
  syncPlan,
711
+ crOpen,
421
712
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/provider-gitea-api",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Gitea REST API forge provider for remogram",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,6 @@
22
22
  "node": ">=20"
23
23
  },
24
24
  "dependencies": {
25
- "@remogram/core": "0.1.0-beta.3"
25
+ "@remogram/core": "0.1.0-beta.4"
26
26
  }
27
27
  }