@remogram/provider-gitea-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,27 +1,109 @@
1
1
  import {
2
2
  fetchJson,
3
+ fetchJsonWithMeta,
3
4
  sanitizeField,
5
+ sanitizeWriteBody,
6
+ sanitizeWriteTitle,
4
7
  sanitizeUrl,
5
8
  assertGitRef,
6
9
  assertGitRemote,
7
10
  gitRevParse,
8
11
  gitCurrentBranch,
9
12
  gitAheadBehind,
13
+ refsInventory,
14
+ crInventory,
15
+ buildMergePlanFromProviderFacts,
16
+ buildChangeRequestOpenedBody,
17
+ buildIssueOpenedBody,
18
+ parseIssueOpenArgs,
19
+ buildCommitStatusSetBody,
20
+ idempotencyPacketFields,
21
+ parseStatusSetArgs,
22
+ normalizeStatusSetState,
23
+ buildProviderIdentityFromGiteaUser,
24
+ buildBranchProtectionFromGiteaProtection,
25
+ buildPrChecksBody,
26
+ buildCrFilesBody,
27
+ buildCrFilesFromGiteaFiles,
28
+ buildCrCommentsBody,
29
+ buildCrCommentsFromGiteaComments,
30
+ buildForgeChangesFromGiteaPulls,
31
+ buildChecksConclusionObservedEvent,
32
+ appendForgeChangeEvents,
33
+ parseSinceObservedAt,
10
34
  ERROR_CODES,
11
35
  forgeError,
36
+ assertExpectedSha,
37
+ LIVE_REACHABILITY_TIMEOUT_MS,
38
+ forgeIngestCapabilityFacts,
39
+ checkPaginationCapabilityFacts,
40
+ idempotencyScanCapabilityFacts,
41
+ openPullListCapabilityFacts,
42
+ DEFAULT_CHECK_STATUS_PAGE_SIZE,
43
+ MAX_CHECK_STATUS_PAGES,
44
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
45
+ MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
46
+ paginateCheckStatusPages,
47
+ paginateOffsetListPages,
48
+ fetchWithIngestPageBackoff,
49
+ fetchPageWithIngestBackoff,
50
+ withPerPageParam,
51
+ apiProviderCommands,
52
+ normalizeCrInventorySort,
53
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
54
+ parseTotalCountHeader,
55
+ isCrInventoryFastPathEligible,
56
+ validateFastPathPageLength,
57
+ isNumberSortFastPathEligible,
58
+ isRecentCreatedFastPathEligible,
59
+ giteaRecentCreatedTailPage,
60
+ isNumberSortFullCollectRequired,
61
+ resolveListTruncatedWithTrustedTotal,
62
+ prepareGiteaOpenPullPageItems,
63
+ orderOpenPullNumbers,
64
+ buildOpenPullListMeta,
65
+ giteaOpenPullSortQuery,
66
+ appendSortQuery,
67
+ assertWriteCommandConfigured,
68
+ fetchWithTimeout,
69
+ readStreamCapped,
70
+ getEffectiveIngestMaxBytes,
71
+ forgeWriteFieldCapabilityFacts,
12
72
  } from '@remogram/core';
73
+ import {
74
+ resolveBranchProtection,
75
+ setBranchProtectionImpl,
76
+ } from './branch-protection-internal.js';
13
77
  const PUBLIC_GITEA_HOST = 'gitea.com';
14
78
  const PUBLIC_GITEA_API = 'https://gitea.com/api/v1';
15
79
  const AUTH_CAPABILITIES = [
16
80
  'repo_status',
17
81
  'ref_compare',
82
+ 'ref_inventory',
83
+ 'cr_inventory',
18
84
  'pr_status',
19
85
  'pr_checks',
20
86
  'merge_plan',
21
87
  'sync_plan',
88
+ 'cr_open',
89
+ 'status_set',
90
+ 'whoami',
91
+ 'branch_protection',
92
+ 'cr_files',
93
+ 'cr_comments',
94
+ 'forge_changes',
22
95
  ];
23
96
 
24
- const STRUCTURED_COMMANDS = AUTH_CAPABILITIES.map((name) => ({ name, implemented: true }));
97
+ const STRUCTURED_COMMANDS = apiProviderCommands({
98
+ writeCommandsImplemented: true,
99
+ issueOpenImplemented: true,
100
+ statusSetImplemented: true,
101
+ branchProtectionImplemented: true,
102
+ crFilesImplemented: true,
103
+ crCommentsImplemented: true,
104
+ forgeChangesImplemented: true,
105
+ mergeExecuteImplemented: true,
106
+ });
25
107
 
26
108
  export function giteaToken() {
27
109
  return process.env.GITEA_TOKEN || null;
@@ -101,13 +183,36 @@ export function authHeaders(token) {
101
183
  }
102
184
 
103
185
  export function repoApiPath(config, ...segments) {
104
- const owner = encodeURIComponent(config.owner);
105
- const repo = encodeURIComponent(config.repo);
106
- const base = `/repos/${owner}/${repo}`;
186
+ return repoApiPathFor(config.owner, config.repo, ...segments);
187
+ }
188
+
189
+ export function repoApiPathFor(owner, repo, ...segments) {
190
+ const encodedOwner = encodeURIComponent(owner);
191
+ const encodedRepo = encodeURIComponent(repo);
192
+ const base = `/repos/${encodedOwner}/${encodedRepo}`;
107
193
  if (!segments.length) return base;
108
194
  return `${base}/${segments.map((s) => encodeURIComponent(String(s))).join('/')}`;
109
195
  }
110
196
 
197
+ export function forgeSourceRepoIdFromPull(config, pr) {
198
+ const headOwner = sanitizeField(pr.head?.repo?.owner?.login ?? pr.head?.repo?.owner?.name);
199
+ const headRepo = sanitizeField(pr.head?.repo?.name);
200
+ if (!headOwner || !headRepo) return null;
201
+ const configOwner = String(config.owner ?? '').toLowerCase();
202
+ const configRepo = String(config.repo ?? '').toLowerCase();
203
+ if (headOwner.toLowerCase() === configOwner && headRepo.toLowerCase() === configRepo) return null;
204
+ return `${headOwner}/${headRepo}`;
205
+ }
206
+
207
+ export function isGiteaHeadOutOfDate409(err) {
208
+ const status = err.status ?? err.forgeError?.status ?? null;
209
+ if (status !== 409) return false;
210
+ const message = err.forgeError?.message ?? err.message ?? '';
211
+ if (/head out of date/i.test(message)) return true;
212
+ if (/sha mismatch/i.test(message)) return true;
213
+ return false;
214
+ }
215
+
111
216
  export async function giteaFetch(config, parsed, path, options = {}) {
112
217
  const token = requireToken();
113
218
  const url = `${apiBase(config, parsed)}${path}`;
@@ -117,6 +222,115 @@ export async function giteaFetch(config, parsed, path, options = {}) {
117
222
  });
118
223
  }
119
224
 
225
+ export async function giteaFetchWithMeta(config, parsed, path, options = {}) {
226
+ const token = requireToken();
227
+ const url = `${apiBase(config, parsed)}${path}`;
228
+ return fetchJsonWithMeta(url, {
229
+ ...options,
230
+ headers: { ...authHeaders(token), ...(options.headers || {}) },
231
+ });
232
+ }
233
+
234
+ export async function apiReachability(ctx) {
235
+ if (!giteaToken()) {
236
+ throw Object.assign(new Error('GITEA_TOKEN not set'), {
237
+ forgeError: forgeError(ERROR_CODES.UNAUTHENTICATED_PROVIDER, 'GITEA_TOKEN not set'),
238
+ });
239
+ }
240
+ const token = requireToken();
241
+ const url = `${apiBase(ctx.config, ctx.parsed)}${repoApiPath(ctx.config)}`;
242
+ await fetchJson(
243
+ url,
244
+ { headers: authHeaders(token) },
245
+ LIVE_REACHABILITY_TIMEOUT_MS,
246
+ );
247
+ return { repo_accessible: true };
248
+ }
249
+
250
+ const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
251
+ const GITEA_PAGE_SIZE = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE;
252
+
253
+ function idempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
254
+ return forgeError(
255
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
256
+ 'Cannot prove no open pull exists for head+base within scan limit; use cr inventory or open manually',
257
+ null,
258
+ {
259
+ idempotency_scan: {
260
+ pages: pagesScanned,
261
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
262
+ page_size: pageSizeUsed,
263
+ },
264
+ },
265
+ );
266
+ }
267
+
268
+ export async function giteaFetchPaginated(config, parsed, path) {
269
+ return paginateCheckStatusPages({
270
+ fetchPage: async ({ page, limit }) => {
271
+ const separator = path.includes('?') ? '&' : '?';
272
+ const body = await giteaFetch(
273
+ config,
274
+ parsed,
275
+ `${path}${separator}limit=${limit}&page=${page}`,
276
+ );
277
+ return Array.isArray(body) ? body : [];
278
+ },
279
+ });
280
+ }
281
+
282
+ export function normalizeGiteaStatusState(state) {
283
+ const normalized = String(state ?? '').toLowerCase();
284
+ if (normalized === 'success' || normalized === 'pass') return 'success';
285
+ if (normalized === 'pending' || normalized === 'running' || normalized === 'waiting') {
286
+ return 'pending';
287
+ }
288
+ if (normalized === 'failure' || normalized === 'fail' || normalized === 'error') {
289
+ return 'failure';
290
+ }
291
+ return 'unknown';
292
+ }
293
+
294
+ function giteaStatusRecordOrder(a, b) {
295
+ const aUpdated = Date.parse(a.updated_at ?? '') || 0;
296
+ const bUpdated = Date.parse(b.updated_at ?? '') || 0;
297
+ if (aUpdated !== bUpdated) return aUpdated - bUpdated;
298
+ const aId = Number(a.id) || 0;
299
+ const bId = Number(b.id) || 0;
300
+ return aId - bId;
301
+ }
302
+
303
+ export function dedupeGiteaStatusRecords(records) {
304
+ const latestByContext = new Map();
305
+ for (const record of records) {
306
+ const context = record?.context;
307
+ if (context == null || context === '') continue;
308
+ const existing = latestByContext.get(context);
309
+ if (!existing || giteaStatusRecordOrder(record, existing) > 0) {
310
+ latestByContext.set(context, record);
311
+ }
312
+ }
313
+ return Array.from(latestByContext.values());
314
+ }
315
+
316
+ export function mapGiteaCommitStatuses(records, { headSha } = {}) {
317
+ return dedupeGiteaStatusRecords(records).map((s) => ({
318
+ context: sanitizeField(s.context),
319
+ state: normalizeGiteaStatusState(s.status ?? s.state),
320
+ description: sanitizeField(s.description),
321
+ ...(s.target_url ? { target_url: sanitizeField(s.target_url) } : {}),
322
+ ...(headSha ? { sha: headSha } : {}),
323
+ source: 'commit_status',
324
+ }));
325
+ }
326
+
327
+ export function normalizeGiteaPrState(state) {
328
+ const normalized = String(state ?? '').toLowerCase();
329
+ if (normalized === 'open') return 'open';
330
+ if (normalized === 'closed') return 'closed';
331
+ return normalized || 'unknown';
332
+ }
333
+
120
334
  export async function repoStatus(ctx) {
121
335
  const token = giteaToken();
122
336
  let defaultBranch = null;
@@ -132,20 +346,278 @@ export async function repoStatus(ctx) {
132
346
  };
133
347
  }
134
348
 
135
- export function providerCapabilities() {
349
+ export async function whoami(ctx) {
350
+ requireToken();
351
+ const user = await giteaFetch(ctx.config, ctx.parsed, '/user');
352
+ return buildProviderIdentityFromGiteaUser(user);
353
+ }
354
+
355
+ export async function branchProtection(ctx, { branchRef }) {
356
+ requireToken();
357
+ try {
358
+ const protection = await giteaFetch(
359
+ ctx.config,
360
+ ctx.parsed,
361
+ repoApiPath(ctx.config, 'branch_protections', branchRef),
362
+ );
363
+ return buildBranchProtectionFromGiteaProtection(branchRef, protection);
364
+ } catch (err) {
365
+ if (err?.status === 404) {
366
+ return buildBranchProtectionFromGiteaProtection(branchRef, null);
367
+ }
368
+ throw err;
369
+ }
370
+ }
371
+
372
+ export async function branchHeadSha(ctx, branchRef, { repoId } = {}) {
373
+ requireToken();
374
+ assertGitRef(branchRef, 'head_ref');
375
+ let owner = ctx.config.owner;
376
+ let repo = ctx.config.repo;
377
+ if (repoId) {
378
+ const parts = String(repoId).split('/');
379
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
380
+ throw Object.assign(new Error('Invalid repoId'), {
381
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'repoId must be owner/repo'),
382
+ });
383
+ }
384
+ owner = parts[0];
385
+ repo = parts[1];
386
+ }
387
+ const branch = await giteaFetch(
388
+ ctx.config,
389
+ ctx.parsed,
390
+ repoApiPathFor(owner, repo, 'branches', branchRef),
391
+ );
392
+ const rawSha = sanitizeField(branch?.commit?.id);
393
+ if (!rawSha) {
394
+ throw Object.assign(new Error('Branch commit id missing'), {
395
+ forgeError: forgeError(
396
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
397
+ 'Gitea branch response missing commit id',
398
+ ),
399
+ });
400
+ }
401
+ try {
402
+ return assertExpectedSha(rawSha, 'branch commit id');
403
+ } catch (err) {
404
+ throw Object.assign(new Error('Branch commit id invalid'), {
405
+ forgeError: forgeError(
406
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
407
+ sanitizeField(err.invalidArgs) || 'Gitea branch response commit id is not a valid SHA',
408
+ ),
409
+ });
410
+ }
411
+ }
412
+
413
+ export async function crFiles(ctx, { number }) {
414
+ requireToken();
415
+ if (number == null) {
416
+ throw Object.assign(new Error('--number required'), {
417
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR changed paths'),
418
+ });
419
+ }
420
+ const path = repoApiPath(ctx.config, 'pulls', number, 'files');
421
+ const pageSep = path.includes('?') ? '&' : '?';
422
+ let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
423
+ const allFiles = [];
424
+ let listTruncated = false;
425
+ let entryCount = 0;
426
+
427
+ for (let page = 1; page <= MAX_CHECK_PAGES; page += 1) {
428
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
429
+ async ({ page: pageNum, limit }) => {
430
+ const body = await giteaFetch(
431
+ ctx.config,
432
+ ctx.parsed,
433
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
434
+ );
435
+ if (!Array.isArray(body)) {
436
+ throw Object.assign(new Error('Provider returned non-array pull files list'), {
437
+ forgeError: forgeError(
438
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
439
+ 'Provider returned non-array pull files list',
440
+ ),
441
+ });
442
+ }
443
+ return body;
444
+ },
445
+ page,
446
+ activeLimit,
447
+ );
448
+ activeLimit = usedLimit;
449
+ entryCount += items.length;
450
+ allFiles.push(...items);
451
+ if (items.length < usedLimit) break;
452
+ if (page === MAX_CHECK_PAGES) {
453
+ listTruncated = true;
454
+ }
455
+ }
456
+
457
+ const body = buildCrFilesFromGiteaFiles(number, allFiles);
458
+ if (listTruncated) {
459
+ return buildCrFilesBody({
460
+ pr_number: body.pr_number,
461
+ changed_paths: body.changed_paths,
462
+ paths_truncated: true,
463
+ path_count: entryCount,
464
+ });
465
+ }
466
+ return body;
467
+ }
468
+
469
+ export async function crComments(ctx, { number }) {
470
+ requireToken();
471
+ if (number == null) {
472
+ throw Object.assign(new Error('--number required'), {
473
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR review comments'),
474
+ });
475
+ }
476
+ const path = repoApiPath(ctx.config, 'pulls', number, 'comments');
477
+ const pageSep = path.includes('?') ? '&' : '?';
478
+ let activeLimit = DEFAULT_CHECK_STATUS_PAGE_SIZE;
479
+ const allComments = [];
480
+ let listTruncated = false;
481
+ let entryCount = 0;
482
+
483
+ for (let page = 1; page <= MAX_CHECK_PAGES; page += 1) {
484
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
485
+ async ({ page: pageNum, limit }) => {
486
+ const body = await giteaFetch(
487
+ ctx.config,
488
+ ctx.parsed,
489
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
490
+ );
491
+ if (!Array.isArray(body)) {
492
+ throw Object.assign(new Error('Provider returned non-array pull comments list'), {
493
+ forgeError: forgeError(
494
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
495
+ 'Provider returned non-array pull comments list',
496
+ ),
497
+ });
498
+ }
499
+ return body;
500
+ },
501
+ page,
502
+ activeLimit,
503
+ );
504
+ activeLimit = usedLimit;
505
+ entryCount += items.length;
506
+ allComments.push(...items);
507
+ if (items.length < usedLimit) break;
508
+ if (page === MAX_CHECK_PAGES) {
509
+ listTruncated = true;
510
+ }
511
+ }
512
+
513
+ const body = buildCrCommentsFromGiteaComments(number, allComments);
514
+ if (listTruncated) {
515
+ return buildCrCommentsBody({
516
+ pr_number: body.pr_number,
517
+ comments: body.comments,
518
+ comments_truncated: true,
519
+ comment_count: entryCount,
520
+ });
521
+ }
522
+ return body;
523
+ }
524
+
525
+ export async function forgeChanges(ctx, { since }) {
526
+ requireToken();
527
+ const sinceIso = parseSinceObservedAt(since);
528
+ const path = `${repoApiPath(ctx.config, 'pulls')}?state=all&sort=recentupdate`;
529
+ const pageSep = '&';
530
+ let activeLimit = GITEA_PAGE_SIZE;
531
+ const allPulls = [];
532
+ let listTruncated = false;
533
+ let entryCount = 0;
534
+
535
+ for (let page = 1; page <= MAX_CHECK_PAGES; page += 1) {
536
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
537
+ async ({ page: pageNum, limit }) => {
538
+ const body = await giteaFetch(
539
+ ctx.config,
540
+ ctx.parsed,
541
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
542
+ );
543
+ if (!Array.isArray(body)) {
544
+ throw Object.assign(new Error('Provider returned non-array pull list'), {
545
+ forgeError: forgeError(
546
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
547
+ 'Provider returned non-array pull list',
548
+ ),
549
+ });
550
+ }
551
+ return body;
552
+ },
553
+ page,
554
+ activeLimit,
555
+ );
556
+ activeLimit = usedLimit;
557
+ entryCount += items.length;
558
+ allPulls.push(...items);
559
+ if (items.length < usedLimit) break;
560
+ if (page === MAX_CHECK_PAGES) {
561
+ listTruncated = true;
562
+ }
563
+ }
564
+
565
+ let body = buildForgeChangesFromGiteaPulls(sinceIso, allPulls, { listTruncated });
566
+ const checkNumbers = new Set();
567
+ for (const event of body.events) {
568
+ if (event.kind === 'pr_opened' || event.kind === 'head_sha_moved') {
569
+ checkNumbers.add(event.pr_number);
570
+ }
571
+ }
572
+
573
+ const checkEvents = [];
574
+ for (const number of checkNumbers) {
575
+ const checks = await prChecks(ctx, { number });
576
+ checkEvents.push(buildChecksConclusionObservedEvent(number, checks));
577
+ }
578
+
579
+ if (checkEvents.length > 0) {
580
+ body = appendForgeChangeEvents(body, checkEvents, { listTruncated });
581
+ }
582
+
583
+ return body;
584
+ }
585
+
586
+ export function providerCapabilities(ctx = {}) {
587
+ const check_sources = ['commit_statuses'];
136
588
  return {
137
589
  commands: STRUCTURED_COMMANDS,
138
590
  auth_envs: ['GITEA_TOKEN'],
139
- check_sources: ['commit_statuses'],
591
+ check_sources,
140
592
  mergeability_confidence: 'direct',
141
593
  host_binding: 'verified_remote_host',
142
- pagination: 'first_page_only',
143
- write_support: false,
594
+ pagination: 'supported',
595
+ write_support: true,
596
+ write_commands: ['cr_open', 'status_set', 'merge', 'issue_open'],
597
+ ...forgeIngestCapabilityFacts(),
598
+ ...forgeWriteFieldCapabilityFacts(ctx.writeFieldPolicy),
599
+ ...checkPaginationCapabilityFacts({
600
+ strategy: 'offset_limit',
601
+ pageSizeParam: 'limit',
602
+ sourceCount: check_sources.length,
603
+ }),
604
+ ...idempotencyScanCapabilityFacts(),
605
+ ...openPullListCapabilityFacts({
606
+ totalCountSource: 'response_header',
607
+ totalCountHeader: 'X-Total-Count',
608
+ sliceSortNotes: {
609
+ recent_created:
610
+ 'sort=oldest; fetches tail page when total exceeds limit; page reversed for newest-first',
611
+ number_asc:
612
+ 'full-list collect within compliant_max when total exceeds limit, then client sort',
613
+ number_desc:
614
+ 'full-list collect within compliant_max when total exceeds limit, then client sort',
615
+ },
616
+ }),
144
617
  };
145
618
  }
146
619
 
147
620
  export async function refsCompare(ctx, baseRef, headRef) {
148
- requireToken();
149
621
  assertGitRef(baseRef, 'base');
150
622
  assertGitRef(headRef, 'head');
151
623
  const baseSha = gitRevParse(ctx.cwd, baseRef);
@@ -157,10 +629,10 @@ export async function refsCompare(ctx, baseRef, headRef) {
157
629
  }
158
630
  const counts = gitAheadBehind(ctx.cwd, baseSha, headSha);
159
631
  return {
160
- base_ref: sanitizeField(baseRef),
161
- base_sha: baseSha,
162
- head_ref: sanitizeField(headRef),
163
- head_sha: headSha,
632
+ compare_base_ref: sanitizeField(baseRef),
633
+ compare_base_sha: baseSha,
634
+ compare_head_ref: sanitizeField(headRef),
635
+ compare_head_sha: headSha,
164
636
  ...counts,
165
637
  };
166
638
  }
@@ -171,7 +643,660 @@ export async function getPull(ctx, { number }) {
171
643
  forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR lookup'),
172
644
  });
173
645
  }
174
- return giteaFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'pulls', number));
646
+ return giteaFetchPullForView(ctx.config, ctx.parsed, number);
647
+ }
648
+
649
+ /** Raw read bound for pull projected ingest (view, write, list) before stripping bulky fields (#478, #574). */
650
+ const GITEA_PULL_INGEST_RAW_READ_MAX = 256 * 1024;
651
+ /** Alias for read-only view call sites. */
652
+ const GITEA_PULL_VIEW_RAW_READ_MAX = GITEA_PULL_INGEST_RAW_READ_MAX;
653
+ /** Raw read bound for issue write/list responses before stripping bulky fields (#572). */
654
+ const GITEA_ISSUE_WRITE_RAW_READ_MAX = 256 * 1024;
655
+
656
+ /** Best-effort regex shrink of bulky pull JSON string fields before JSON.parse (#478, #574).
657
+ * Fail-closed via readStreamCapped(rawReadMax) and getEffectiveIngestMaxBytes() after strip. */
658
+ function stripGiteaPullBulkJsonFields(raw) {
659
+ return String(raw)
660
+ .replace(/"body"\s*:\s*"(?:\\.|[^"\\])*"/g, '"body":""')
661
+ .replace(/"body_html"\s*:\s*"(?:\\.|[^"\\])*"/g, '"body_html":""')
662
+ .replace(/"diff"\s*:\s*"(?:\\.|[^"\\])*"/g, '"diff":""')
663
+ .replace(/"patch"\s*:\s*"(?:\\.|[^"\\])*"/g, '"patch":""');
664
+ }
665
+
666
+ function stripGiteaIssueBulkJsonFields(raw) {
667
+ return String(raw)
668
+ .replace(/"body"\s*:\s*"(?:\\.|[^"\\])*"/g, '"body":""')
669
+ .replace(/"body_html"\s*:\s*"(?:\\.|[^"\\])*"/g, '"body_html":""');
670
+ }
671
+
672
+ async function parseProjectedGiteaResponse(res, { stripFn, rawReadMax }) {
673
+ if (res.status >= 300 && res.status < 400) {
674
+ const message = 'HTTP redirect rejected';
675
+ throw Object.assign(new Error(message), {
676
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
677
+ status: res.status,
678
+ });
679
+ }
680
+ const capped = await readStreamCapped(res.body, rawReadMax);
681
+ if (capped.truncated) {
682
+ throw Object.assign(new Error('Provider output exceeded cap'), {
683
+ forgeError: forgeError(ERROR_CODES.OVERSIZED_RAW_OUTPUT, 'Provider response exceeded byte cap'),
684
+ status: res.status,
685
+ });
686
+ }
687
+ const stripped = stripFn(capped.text);
688
+ if (Buffer.byteLength(stripped, 'utf8') > getEffectiveIngestMaxBytes().bytes) {
689
+ throw Object.assign(new Error('Provider output exceeded cap after projection'), {
690
+ forgeError: forgeError(ERROR_CODES.OVERSIZED_RAW_OUTPUT, 'Provider response exceeded byte cap'),
691
+ status: res.status,
692
+ });
693
+ }
694
+ let body;
695
+ try {
696
+ body = stripped ? JSON.parse(stripped) : null;
697
+ } catch {
698
+ throw Object.assign(new Error('Unparseable JSON from provider'), {
699
+ forgeError: forgeError(ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT, 'Provider returned invalid JSON'),
700
+ status: res.status,
701
+ });
702
+ }
703
+ if (!res.ok) {
704
+ const raw = body?.message || body?.error || res.statusText || 'API error';
705
+ const message = sanitizeField(raw) || 'API error';
706
+ throw Object.assign(new Error(message), {
707
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
708
+ status: res.status,
709
+ });
710
+ }
711
+ return body;
712
+ }
713
+
714
+ async function giteaFetchProjected(config, parsed, path, { stripFn, rawReadMax, requestOptions = {} }) {
715
+ const token = requireToken();
716
+ const url = `${apiBase(config, parsed)}${path}`;
717
+ const res = await fetchWithTimeout(url, {
718
+ ...requestOptions,
719
+ headers: { ...authHeaders(token), ...(requestOptions.headers || {}) },
720
+ });
721
+ return parseProjectedGiteaResponse(res, { stripFn, rawReadMax });
722
+ }
723
+
724
+ async function giteaFetchPullForView(config, parsed, number) {
725
+ return giteaFetchProjected(config, parsed, repoApiPath(config, 'pulls', number), {
726
+ stripFn: stripGiteaPullBulkJsonFields,
727
+ rawReadMax: GITEA_PULL_VIEW_RAW_READ_MAX,
728
+ });
729
+ }
730
+
731
+ async function giteaFetchIssueWriteResponse(config, parsed, path, requestOptions = {}) {
732
+ return giteaFetchProjected(config, parsed, path, {
733
+ stripFn: stripGiteaIssueBulkJsonFields,
734
+ rawReadMax: GITEA_ISSUE_WRITE_RAW_READ_MAX,
735
+ requestOptions,
736
+ });
737
+ }
738
+
739
+ async function giteaFetchPullWriteResponse(config, parsed, path, requestOptions = {}) {
740
+ return giteaFetchProjected(config, parsed, path, {
741
+ stripFn: stripGiteaPullBulkJsonFields,
742
+ rawReadMax: GITEA_PULL_INGEST_RAW_READ_MAX,
743
+ requestOptions,
744
+ });
745
+ }
746
+
747
+ /** Paginated open-pull scan for idempotent cr open; fail-closed when scan cap prevents proof of absence. */
748
+ export async function findOpenPullByHeadBase(ctx, head, base) {
749
+ requireToken();
750
+ const path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
751
+ const pageSep = path.includes('?') ? '&' : '?';
752
+ let activeLimit = GITEA_PAGE_SIZE;
753
+
754
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
755
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
756
+ async ({ page: pageNum, limit }) => {
757
+ const body = await giteaFetchProjected(
758
+ ctx.config,
759
+ ctx.parsed,
760
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
761
+ {
762
+ stripFn: stripGiteaPullBulkJsonFields,
763
+ rawReadMax: GITEA_PULL_INGEST_RAW_READ_MAX,
764
+ },
765
+ );
766
+ if (!Array.isArray(body)) {
767
+ throw Object.assign(new Error('Provider returned non-array open pull list'), {
768
+ forgeError: forgeError(
769
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
770
+ 'Provider returned non-array open pull list',
771
+ ),
772
+ });
773
+ }
774
+ return body;
775
+ },
776
+ page,
777
+ activeLimit,
778
+ );
779
+ activeLimit = usedLimit;
780
+
781
+ const match =
782
+ items.find(
783
+ (pr) =>
784
+ String(pr?.state ?? '').toLowerCase() === 'open' &&
785
+ pr?.head?.ref === head &&
786
+ pr?.base?.ref === base,
787
+ ) ?? null;
788
+ if (match) return match;
789
+ if (items.length < usedLimit) return null;
790
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
791
+ throw Object.assign(new Error('Open pull idempotency scan incomplete'), {
792
+ forgeError: idempotencyScanIncompleteError(page, usedLimit),
793
+ });
794
+ }
795
+ }
796
+ return null;
797
+ }
798
+
799
+ function issueOpenIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
800
+ return forgeError(
801
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
802
+ 'Cannot prove no open issue exists for title within scan limit; retry or open manually',
803
+ null,
804
+ {
805
+ idempotency_scan: {
806
+ pages: pagesScanned,
807
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
808
+ page_size: pageSizeUsed,
809
+ },
810
+ },
811
+ );
812
+ }
813
+
814
+ /** Paginated open-issue scan for idempotent issue open; fail-closed when scan cap prevents proof of absence. */
815
+ export async function findOpenIssueByTitle(ctx, title) {
816
+ requireToken();
817
+ const path = `${repoApiPath(ctx.config, 'issues')}?state=open`;
818
+ const pageSep = path.includes('?') ? '&' : '?';
819
+ let activeLimit = GITEA_PAGE_SIZE;
820
+
821
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
822
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
823
+ async ({ page: pageNum, limit }) => {
824
+ const body = await giteaFetchProjected(
825
+ ctx.config,
826
+ ctx.parsed,
827
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
828
+ {
829
+ stripFn: stripGiteaIssueBulkJsonFields,
830
+ rawReadMax: GITEA_ISSUE_WRITE_RAW_READ_MAX,
831
+ },
832
+ );
833
+ if (!Array.isArray(body)) {
834
+ throw Object.assign(new Error('Provider returned non-array open issue list'), {
835
+ forgeError: forgeError(
836
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
837
+ 'Provider returned non-array open issue list',
838
+ ),
839
+ });
840
+ }
841
+ return body;
842
+ },
843
+ page,
844
+ activeLimit,
845
+ );
846
+ activeLimit = usedLimit;
847
+
848
+ const match =
849
+ items.find(
850
+ (issue) =>
851
+ String(issue?.state ?? '').toLowerCase() === 'open' &&
852
+ sanitizeField(issue?.title ?? '') === title,
853
+ ) ?? null;
854
+ if (match) return match;
855
+ if (items.length < usedLimit) return null;
856
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
857
+ throw Object.assign(new Error('Open issue idempotency scan incomplete'), {
858
+ forgeError: issueOpenIdempotencyScanIncompleteError(page, usedLimit),
859
+ });
860
+ }
861
+ }
862
+ return null;
863
+ }
864
+
865
+ function statusSetIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
866
+ return forgeError(
867
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
868
+ 'Cannot prove no commit status exists for sha+context within scan limit; retry or set manually',
869
+ null,
870
+ {
871
+ idempotency_scan: {
872
+ pages: pagesScanned,
873
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
874
+ page_size: pageSizeUsed,
875
+ },
876
+ },
877
+ );
878
+ }
879
+
880
+ /** Paginated commit-status scan for idempotent status set; fail-closed when scan cap prevents proof of absence. */
881
+ export async function findCommitStatusByContext(ctx, sha, context) {
882
+ requireToken();
883
+ const path = repoApiPath(ctx.config, 'commits', sha, 'statuses');
884
+ const pageSep = path.includes('?') ? '&' : '?';
885
+ let activeLimit = GITEA_PAGE_SIZE;
886
+ let bestMatch = null;
887
+
888
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
889
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
890
+ async ({ page: pageNum, limit }) => {
891
+ const body = await giteaFetch(
892
+ ctx.config,
893
+ ctx.parsed,
894
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
895
+ );
896
+ if (!Array.isArray(body)) {
897
+ throw Object.assign(new Error('Provider returned non-array commit status list'), {
898
+ forgeError: forgeError(
899
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
900
+ 'Provider returned non-array commit status list',
901
+ ),
902
+ });
903
+ }
904
+ return body;
905
+ },
906
+ page,
907
+ activeLimit,
908
+ );
909
+ activeLimit = usedLimit;
910
+
911
+ for (const record of items) {
912
+ if (record?.context !== context) continue;
913
+ if (!bestMatch || giteaStatusRecordOrder(record, bestMatch) > 0) {
914
+ bestMatch = record;
915
+ }
916
+ }
917
+
918
+ if (items.length < usedLimit) return bestMatch;
919
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
920
+ throw Object.assign(new Error('Commit status idempotency scan incomplete'), {
921
+ forgeError: statusSetIdempotencyScanIncompleteError(page, usedLimit),
922
+ });
923
+ }
924
+ }
925
+ return bestMatch;
926
+ }
927
+
928
+ export async function statusSet(ctx, args) {
929
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'status_set');
930
+ const { idempotencyFingerprint = null, ...rest } = args;
931
+ const parsed = parseStatusSetArgs(rest, ctx.writeFieldPolicy);
932
+ const existing = await findCommitStatusByContext(ctx, parsed.sha, parsed.context);
933
+ if (existing) {
934
+ const existingState = normalizeStatusSetState(existing.status ?? existing.state);
935
+ if (existingState === parsed.state) {
936
+ return buildCommitStatusSetBody(existing, parsed, {
937
+ reusedExisting: true,
938
+ idempotencyFields: idempotencyFingerprint
939
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
940
+ : null,
941
+ });
942
+ }
943
+ }
944
+ const payload = {
945
+ state: parsed.state,
946
+ context: parsed.context,
947
+ };
948
+ if (parsed.description != null) payload.description = parsed.description;
949
+ if (parsed.target_url != null) payload.target_url = parsed.target_url;
950
+ const response = await giteaFetch(
951
+ ctx.config,
952
+ ctx.parsed,
953
+ repoApiPath(ctx.config, 'statuses', parsed.sha),
954
+ {
955
+ method: 'POST',
956
+ headers: { 'Content-Type': 'application/json' },
957
+ body: JSON.stringify(payload),
958
+ },
959
+ );
960
+ return buildCommitStatusSetBody(response, parsed, {
961
+ idempotencyFields: idempotencyFingerprint
962
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
963
+ : null,
964
+ } );
965
+ }
966
+
967
+ export async function issueOpen(ctx, { title, body: issueBody, idempotencyFingerprint = null }) {
968
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'issue_open');
969
+ const parsed = parseIssueOpenArgs({ title, body: issueBody }, ctx.writeFieldPolicy);
970
+ const existing = await findOpenIssueByTitle(ctx, parsed.title);
971
+ if (existing) {
972
+ return buildIssueOpenedBody(
973
+ existing,
974
+ { title: parsed.title },
975
+ {
976
+ reusedExisting: true,
977
+ idempotencyFields: idempotencyFingerprint
978
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
979
+ : null,
980
+ },
981
+ );
982
+ }
983
+ const payload = { title: parsed.title };
984
+ if (parsed.body != null) payload.body = parsed.body;
985
+ const issue = await giteaFetchIssueWriteResponse(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'issues'), {
986
+ method: 'POST',
987
+ headers: { 'Content-Type': 'application/json' },
988
+ body: JSON.stringify(payload),
989
+ });
990
+ return buildIssueOpenedBody(
991
+ issue,
992
+ { title: parsed.title },
993
+ {
994
+ idempotencyFields: idempotencyFingerprint
995
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
996
+ : null,
997
+ },
998
+ );
999
+ }
1000
+
1001
+ export async function crOpen(ctx, { head, base, title, body: prBody, idempotencyFingerprint = null }) {
1002
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'cr_open');
1003
+ assertGitRef(head, 'head');
1004
+ assertGitRef(base, 'base');
1005
+ if (!title || typeof title !== 'string' || !title.trim()) {
1006
+ throw Object.assign(new Error('--title required'), {
1007
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, '--title required for cr open'),
1008
+ });
1009
+ }
1010
+ const writeFieldPolicy = ctx.writeFieldPolicy ?? null;
1011
+ const payload = {
1012
+ title: sanitizeWriteTitle(title, writeFieldPolicy),
1013
+ head: sanitizeField(head),
1014
+ base: sanitizeField(base),
1015
+ };
1016
+ if (prBody != null && String(prBody).trim() !== '') {
1017
+ payload.body = sanitizeWriteBody(String(prBody), writeFieldPolicy);
1018
+ }
1019
+ const existing = await findOpenPullByHeadBase(ctx, payload.head, payload.base);
1020
+ if (existing) {
1021
+ return buildChangeRequestOpenedBody(
1022
+ existing,
1023
+ { head, base, title },
1024
+ {
1025
+ reusedExisting: true,
1026
+ idempotencyFields: idempotencyFingerprint
1027
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
1028
+ : null,
1029
+ },
1030
+ );
1031
+ }
1032
+ const pull = await giteaFetchPullWriteResponse(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'pulls'), {
1033
+ method: 'POST',
1034
+ headers: { 'Content-Type': 'application/json' },
1035
+ body: JSON.stringify(payload),
1036
+ });
1037
+ return buildChangeRequestOpenedBody(
1038
+ pull,
1039
+ { head, base, title },
1040
+ {
1041
+ idempotencyFields: idempotencyFingerprint
1042
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
1043
+ : null,
1044
+ },
1045
+ );
1046
+ }
1047
+
1048
+ export async function mergeExecute(ctx, { number, method = 'merge', expectedHeadSha }) {
1049
+ assertWriteCommandConfigured(ctx.writePolicy ?? ctx.config, 'merge');
1050
+ if (method !== 'merge') {
1051
+ throw Object.assign(new Error('Unsupported merge method'), {
1052
+ forgeError: forgeError(
1053
+ ERROR_CODES.INVALID_ARGS,
1054
+ 'Only --method merge is supported in v1',
1055
+ ),
1056
+ });
1057
+ }
1058
+ const pullIndex = Number(number);
1059
+ if (!Number.isInteger(pullIndex) || pullIndex <= 0) {
1060
+ throw Object.assign(new Error('Invalid PR number'), {
1061
+ forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'PR number must be a positive integer'),
1062
+ });
1063
+ }
1064
+ const headCommitId = assertExpectedSha(expectedHeadSha, 'expectedHeadSha');
1065
+ let result;
1066
+ try {
1067
+ result = await giteaFetch(
1068
+ ctx.config,
1069
+ ctx.parsed,
1070
+ repoApiPath(ctx.config, 'pulls', String(pullIndex), 'merge'),
1071
+ {
1072
+ method: 'POST',
1073
+ headers: { 'Content-Type': 'application/json' },
1074
+ body: JSON.stringify({ Do: 'merge', head_commit_id: headCommitId }),
1075
+ },
1076
+ );
1077
+ } catch (err) {
1078
+ const status = err.status ?? err.forgeError?.status ?? null;
1079
+ const message = err.forgeError?.message ?? err.message ?? '';
1080
+ if (isGiteaHeadOutOfDate409(err)) {
1081
+ throw Object.assign(new Error(message), {
1082
+ status,
1083
+ mergeBlockedBlockers: ['head_ref_moved'],
1084
+ forgeError: forgeError(
1085
+ ERROR_CODES.MERGE_BLOCKED,
1086
+ sanitizeField(message) || 'Head branch out of date at merge POST',
1087
+ status,
1088
+ ),
1089
+ });
1090
+ }
1091
+ throw err;
1092
+ }
1093
+ return {
1094
+ commit_sha: sanitizeField(result?.sha ?? result?.merge_commit_sha ?? null),
1095
+ provider_status: 200,
1096
+ base_sha: sanitizeField(result?.base_sha ?? null),
1097
+ };
1098
+ }
1099
+
1100
+ const GITEA_OPEN_PULL_COMPLIANT_MAX =
1101
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
1102
+
1103
+ async function probeGiteaOpenPullPageOne(ctx, retainMax, sliceSort) {
1104
+ const maxTrusted = GITEA_OPEN_PULL_COMPLIANT_MAX * 2;
1105
+ let path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
1106
+ path = appendSortQuery(path, giteaOpenPullSortQuery(sliceSort));
1107
+ const pageSep = path.includes('?') ? '&' : '?';
1108
+ const requestLimit = Math.min(retainMax, GITEA_PAGE_SIZE);
1109
+ try {
1110
+ const { body, headers } = await giteaFetchWithMeta(
1111
+ ctx.config,
1112
+ ctx.parsed,
1113
+ `${path}${pageSep}limit=${requestLimit}&page=1`,
1114
+ );
1115
+ if (!Array.isArray(body)) return null;
1116
+ const totalCount = parseTotalCountHeader(headers, 'X-Total-Count', { maxTrusted });
1117
+ if (totalCount == null) return null;
1118
+ const listTruncated = totalCount > GITEA_OPEN_PULL_COMPLIANT_MAX;
1119
+ return { body, totalCount, listTruncated, requestLimit };
1120
+ } catch {
1121
+ return null;
1122
+ }
1123
+ }
1124
+
1125
+ function buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, listTruncated) {
1126
+ const pageItems = prepareGiteaOpenPullPageItems(body, sliceSort);
1127
+ let numbers = orderOpenPullNumbers(pageItems, (pr) => pr?.number, sliceSort);
1128
+ if (numbers.length > retainMax) numbers = numbers.slice(0, retainMax);
1129
+ return buildOpenPullListMeta({
1130
+ totalCount,
1131
+ numbers,
1132
+ listTruncated,
1133
+ sliceSort,
1134
+ });
1135
+ }
1136
+
1137
+ async function fetchGiteaRecentCreatedTailSlice(ctx, retainMax, sliceSort, totalCount) {
1138
+ const tailPage = giteaRecentCreatedTailPage(totalCount, GITEA_PAGE_SIZE);
1139
+ let path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
1140
+ path = appendSortQuery(path, giteaOpenPullSortQuery(sliceSort));
1141
+ const pageSep = path.includes('?') ? '&' : '?';
1142
+ let body;
1143
+ try {
1144
+ body = await giteaFetch(
1145
+ ctx.config,
1146
+ ctx.parsed,
1147
+ `${path}${pageSep}limit=${GITEA_PAGE_SIZE}&page=${tailPage}`,
1148
+ );
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ if (!Array.isArray(body) || body.length === 0) return null;
1153
+ return buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
1154
+ }
1155
+
1156
+ function giteaProbePaginationOpts(probe, extra = {}) {
1157
+ const { body, totalCount, requestLimit } = probe;
1158
+ return {
1159
+ trustedTotalCount: totalCount,
1160
+ seededFirstPage: { items: body, usedLimit: requestLimit },
1161
+ ...extra,
1162
+ };
1163
+ }
1164
+
1165
+ async function paginateGiteaOpenPullList(ctx, opts, sliceSort, paginationOpts = {}) {
1166
+ const {
1167
+ trustedTotalCount = null,
1168
+ numberSortFullCollect = false,
1169
+ seededFirstPage = null,
1170
+ startPage = 1,
1171
+ maxPages = MAX_CHECK_STATUS_PAGES,
1172
+ suppressFinalPageProbe = false,
1173
+ } = paginationOpts;
1174
+ const listLimit =
1175
+ opts.limit != null && Number.isInteger(Number(opts.limit)) && Number(opts.limit) > 0
1176
+ ? Number(opts.limit)
1177
+ : null;
1178
+ const retainMax =
1179
+ listLimit == null &&
1180
+ opts.retain_max != null &&
1181
+ Number.isInteger(Number(opts.retain_max)) &&
1182
+ Number(opts.retain_max) > 0
1183
+ ? Number(opts.retain_max)
1184
+ : null;
1185
+ const pageSize =
1186
+ listLimit != null ? Math.min(listLimit, GITEA_PAGE_SIZE) : GITEA_PAGE_SIZE;
1187
+ let path = `${repoApiPath(ctx.config, 'pulls')}?state=open`;
1188
+ path = appendSortQuery(path, giteaOpenPullSortQuery(sliceSort));
1189
+ const pageSep = path.includes('?') ? '&' : '?';
1190
+ const effectiveRetainMax = numberSortFullCollect ? null : retainMax;
1191
+ const {
1192
+ items: all,
1193
+ list_truncated: listTruncated,
1194
+ entry_count: entryCount,
1195
+ walked_count: walkedCount,
1196
+ } = await paginateOffsetListPages({
1197
+ pageSize,
1198
+ listLimit,
1199
+ retainMax: effectiveRetainMax,
1200
+ trustedEntryCount: trustedTotalCount,
1201
+ seededFirstPage,
1202
+ startPage,
1203
+ maxPages,
1204
+ suppressFinalPageProbe,
1205
+ ...(listLimit != null && pageSize < listLimit ? { maxPagesTruncatesWithLimit: true } : {}),
1206
+ fetchPage: async ({ page, limit }) => {
1207
+ const body = await giteaFetch(
1208
+ ctx.config,
1209
+ ctx.parsed,
1210
+ `${path}${pageSep}limit=${limit}&page=${page}`,
1211
+ );
1212
+ return Array.isArray(body) ? body : [];
1213
+ },
1214
+ });
1215
+ let numbers = orderOpenPullNumbers(
1216
+ prepareGiteaOpenPullPageItems(all, sliceSort),
1217
+ (pr) => pr?.number,
1218
+ sliceSort,
1219
+ );
1220
+ const outputCap = listLimit ?? retainMax;
1221
+ if (outputCap != null && numbers.length > outputCap) {
1222
+ numbers = numbers.slice(0, outputCap);
1223
+ }
1224
+ return {
1225
+ numbers,
1226
+ list_truncated: resolveListTruncatedWithTrustedTotal({
1227
+ listTruncated,
1228
+ trustedTotalCount,
1229
+ walkedCount,
1230
+ fullCollect: numberSortFullCollect,
1231
+ }),
1232
+ ...(entryCount != null ? { entry_count: entryCount } : {}),
1233
+ slice_sort: sliceSort,
1234
+ };
1235
+ }
1236
+
1237
+ export async function listOpenPullsWithMeta(ctx, opts = {}) {
1238
+ requireToken();
1239
+ const sliceSort = normalizeCrInventorySort(opts.sort ?? DEFAULT_CR_INVENTORY_SLICE_SORT);
1240
+ if (!isCrInventoryFastPathEligible(opts)) {
1241
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort);
1242
+ }
1243
+
1244
+ const retainMax = Number(opts.retain_max);
1245
+ const probe = await probeGiteaOpenPullPageOne(ctx, retainMax, sliceSort);
1246
+ if (!probe) {
1247
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort);
1248
+ }
1249
+
1250
+ const { body, totalCount, listTruncated, requestLimit } = probe;
1251
+
1252
+ if (listTruncated) {
1253
+ if (body.length === 0) {
1254
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort, giteaProbePaginationOpts(probe));
1255
+ }
1256
+ return buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, true);
1257
+ }
1258
+
1259
+ if (
1260
+ sliceSort === 'recent_created' &&
1261
+ !isRecentCreatedFastPathEligible(totalCount, retainMax, sliceSort, 'gitea-api')
1262
+ ) {
1263
+ const tail = await fetchGiteaRecentCreatedTailSlice(ctx, retainMax, sliceSort, totalCount);
1264
+ if (tail) return tail;
1265
+ const tailRetry = await fetchGiteaRecentCreatedTailSlice(ctx, retainMax, sliceSort, totalCount);
1266
+ if (tailRetry) return tailRetry;
1267
+ const tailPage = giteaRecentCreatedTailPage(totalCount, GITEA_PAGE_SIZE);
1268
+ return paginateGiteaOpenPullList(ctx, opts, sliceSort, {
1269
+ trustedTotalCount: totalCount,
1270
+ startPage: tailPage,
1271
+ maxPages: tailPage,
1272
+ suppressFinalPageProbe: true,
1273
+ });
1274
+ }
1275
+
1276
+ if (
1277
+ isRecentCreatedFastPathEligible(totalCount, retainMax, sliceSort, 'gitea-api') &&
1278
+ isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) &&
1279
+ validateFastPathPageLength(totalCount, requestLimit, body.length)
1280
+ ) {
1281
+ return buildGiteaOpenPullMetaFromPage(body, retainMax, sliceSort, totalCount, false);
1282
+ }
1283
+
1284
+ const numberSortFullCollect = isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort);
1285
+ return paginateGiteaOpenPullList(
1286
+ ctx,
1287
+ opts,
1288
+ sliceSort,
1289
+ giteaProbePaginationOpts(probe, { numberSortFullCollect }),
1290
+ );
1291
+ }
1292
+
1293
+ export async function listOpenPulls(ctx, opts = {}) {
1294
+ const meta = await listOpenPullsWithMeta(ctx, opts);
1295
+ return meta.numbers;
1296
+ }
1297
+
1298
+ export async function crInventorySlice(ctx, opts = {}) {
1299
+ return crInventory(ctx, { listOpenPulls, listOpenPullsWithMeta, prView, prChecks }, opts);
175
1300
  }
176
1301
 
177
1302
  function mergeability(pr) {
@@ -182,22 +1307,26 @@ function mergeability(pr) {
182
1307
 
183
1308
  export async function prView(ctx, opts) {
184
1309
  const pr = await getPull(ctx, opts);
185
- return {
1310
+ const body = {
186
1311
  pr_number: pr.number,
187
1312
  url: sanitizeUrl(pr.html_url ?? pr.url),
188
1313
  title: sanitizeField(pr.title),
189
- state: sanitizeField(pr.state),
190
- base_ref: sanitizeField(pr.base?.ref),
191
- base_sha: sanitizeField(pr.base?.sha),
192
- head_ref: sanitizeField(pr.head?.ref),
193
- head_sha: sanitizeField(pr.head?.sha),
1314
+ state: normalizeGiteaPrState(pr.state),
1315
+ forge_target_branch_ref: sanitizeField(pr.base?.ref),
1316
+ forge_target_sha: sanitizeField(pr.base?.sha),
1317
+ forge_source_branch_ref: sanitizeField(pr.head?.ref),
1318
+ forge_source_sha: sanitizeField(pr.head?.sha),
194
1319
  mergeability: mergeability(pr),
195
1320
  };
1321
+ const forgeSourceRepoId = forgeSourceRepoIdFromPull(ctx.config, pr);
1322
+ if (forgeSourceRepoId) body.forge_source_repo_id = forgeSourceRepoId;
1323
+ return body;
196
1324
  }
197
1325
 
198
1326
  export async function prChecks(ctx, opts) {
199
1327
  requireToken();
200
1328
  let sha;
1329
+ let requiredContexts = [];
201
1330
  if (opts.ref) {
202
1331
  assertGitRef(opts.ref, 'ref');
203
1332
  sha = gitRevParse(ctx.cwd, opts.ref);
@@ -209,27 +1338,37 @@ export async function prChecks(ctx, opts) {
209
1338
  } else {
210
1339
  const pr = await getPull(ctx, opts);
211
1340
  sha = pr.head?.sha;
1341
+ const targetBranch = pr.base?.ref;
1342
+ if (targetBranch) {
1343
+ try {
1344
+ const protection = await resolveBranchProtection(ctx, { branchRef: targetBranch });
1345
+ requiredContexts = protection.required_status_contexts ?? [];
1346
+ } catch (err) {
1347
+ if (err?.status !== 404) throw err;
1348
+ }
1349
+ }
212
1350
  }
213
1351
  if (!sha) {
214
1352
  throw Object.assign(new Error('No SHA'), {
215
1353
  forgeError: forgeError(ERROR_CODES.MISSING_REF, 'Could not determine head SHA for checks'),
216
1354
  });
217
1355
  }
218
- const statuses = await giteaFetch(
1356
+ const { items: statusRecords, truncated: checks_truncated } = await giteaFetchPaginated(
219
1357
  ctx.config,
220
1358
  ctx.parsed,
221
1359
  repoApiPath(ctx.config, 'commits', sha, 'statuses'),
222
1360
  );
223
- const mapped = (statuses || []).map((s) => ({
224
- context: sanitizeField(s.context),
225
- state: sanitizeField(s.state),
226
- description: sanitizeField(s.description),
227
- }));
228
- const conclusion = summarizeChecks(mapped);
229
- return { head_sha: sha, check_conclusion: conclusion, statuses: mapped };
1361
+ const mapped = mapGiteaCommitStatuses(statusRecords, { headSha: sha });
1362
+ return buildPrChecksBody({
1363
+ forge_source_sha: sha,
1364
+ check_conclusion: summarizeChecks(mapped),
1365
+ checks_truncated,
1366
+ statuses: mapped,
1367
+ required_contexts: requiredContexts,
1368
+ });
230
1369
  }
231
1370
 
232
- function summarizeChecks(statuses) {
1371
+ export function summarizeChecks(statuses) {
233
1372
  if (!statuses.length) return 'missing';
234
1373
  if (statuses.some((s) => s.state === 'failure' || s.state === 'error')) return 'failure';
235
1374
  if (statuses.some((s) => s.state === 'pending')) return 'pending';
@@ -238,20 +1377,7 @@ function summarizeChecks(statuses) {
238
1377
  }
239
1378
 
240
1379
  export async function mergePlan(ctx, opts) {
241
- const view = await prView(ctx, opts);
242
- const checks = await prChecks(ctx, { number: view.pr_number });
243
- const blockers = [];
244
- if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
245
- if (view.state !== 'open') blockers.push('pr_not_open');
246
- if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
247
- if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
248
- if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
249
- return {
250
- pr_number: view.pr_number,
251
- mergeability: view.mergeability,
252
- checks_conclusion: checks.check_conclusion,
253
- blockers,
254
- };
1380
+ return buildMergePlanFromProviderFacts(ctx, opts, { prView, prChecks, crFiles });
255
1381
  }
256
1382
 
257
1383
  export async function syncPlan(ctx, remoteName = 'origin') {
@@ -284,10 +1410,26 @@ export async function syncPlan(ctx, remoteName = 'origin') {
284
1410
  export const provider = {
285
1411
  id: 'gitea-api',
286
1412
  providerCapabilities,
1413
+ apiReachability,
287
1414
  repoStatus,
288
1415
  refsCompare,
1416
+ refsInventory,
1417
+ listOpenPulls,
1418
+ crInventory: crInventorySlice,
289
1419
  prView,
290
1420
  prChecks,
291
1421
  mergePlan,
292
1422
  syncPlan,
1423
+ crOpen,
1424
+ issueOpen,
1425
+ mergeExecute,
1426
+ statusSet,
1427
+ whoami,
1428
+ branchProtection,
1429
+ branchHeadSha,
1430
+ crFiles,
1431
+ crComments,
1432
+ forgeChanges,
293
1433
  };
1434
+
1435
+ setBranchProtectionImpl(branchProtection);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/provider-gitea-api",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0-beta.10",
4
4
  "description": "Gitea REST API forge 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
  }