@remogram/provider-gitea-api 0.1.0-beta.8 → 0.1.0-beta.9

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
@@ -12,11 +12,15 @@ import {
12
12
  crInventory,
13
13
  buildMergePlanFromProviderFacts,
14
14
  buildChangeRequestOpenedBody,
15
+ buildIssueOpenedBody,
16
+ parseIssueOpenArgs,
15
17
  buildCommitStatusSetBody,
18
+ idempotencyPacketFields,
16
19
  parseStatusSetArgs,
17
20
  normalizeStatusSetState,
18
21
  buildProviderIdentityFromGiteaUser,
19
22
  buildBranchProtectionFromGiteaProtection,
23
+ buildPrChecksBody,
20
24
  buildCrFilesBody,
21
25
  buildCrFilesFromGiteaFiles,
22
26
  buildCrCommentsBody,
@@ -28,6 +32,7 @@ import {
28
32
  ERROR_CODES,
29
33
  forgeError,
30
34
  assertExpectedSha,
35
+ LIVE_REACHABILITY_TIMEOUT_MS,
31
36
  forgeIngestCapabilityFacts,
32
37
  checkPaginationCapabilityFacts,
33
38
  idempotencyScanCapabilityFacts,
@@ -58,7 +63,14 @@ import {
58
63
  giteaOpenPullSortQuery,
59
64
  appendSortQuery,
60
65
  assertWriteCommandConfigured,
66
+ fetchWithTimeout,
67
+ readStreamCapped,
68
+ getEffectiveIngestMaxBytes,
61
69
  } from '@remogram/core';
70
+ import {
71
+ resolveBranchProtection,
72
+ setBranchProtectionImpl,
73
+ } from './branch-protection-internal.js';
62
74
  const PUBLIC_GITEA_HOST = 'gitea.com';
63
75
  const PUBLIC_GITEA_API = 'https://gitea.com/api/v1';
64
76
  const AUTH_CAPABILITIES = [
@@ -81,6 +93,7 @@ const AUTH_CAPABILITIES = [
81
93
 
82
94
  const STRUCTURED_COMMANDS = apiProviderCommands({
83
95
  writeCommandsImplemented: true,
96
+ issueOpenImplemented: true,
84
97
  statusSetImplemented: true,
85
98
  branchProtectionImplemented: true,
86
99
  crFilesImplemented: true,
@@ -215,6 +228,22 @@ export async function giteaFetchWithMeta(config, parsed, path, options = {}) {
215
228
  });
216
229
  }
217
230
 
231
+ export async function apiReachability(ctx) {
232
+ if (!giteaToken()) {
233
+ throw Object.assign(new Error('GITEA_TOKEN not set'), {
234
+ forgeError: forgeError(ERROR_CODES.UNAUTHENTICATED_PROVIDER, 'GITEA_TOKEN not set'),
235
+ });
236
+ }
237
+ const token = requireToken();
238
+ const url = `${apiBase(ctx.config, ctx.parsed)}${repoApiPath(ctx.config)}`;
239
+ await fetchJson(
240
+ url,
241
+ { headers: authHeaders(token) },
242
+ LIVE_REACHABILITY_TIMEOUT_MS,
243
+ );
244
+ return { repo_accessible: true };
245
+ }
246
+
218
247
  const MAX_CHECK_PAGES = MAX_CHECK_STATUS_PAGES;
219
248
  const GITEA_PAGE_SIZE = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE;
220
249
 
@@ -281,11 +310,14 @@ export function dedupeGiteaStatusRecords(records) {
281
310
  return Array.from(latestByContext.values());
282
311
  }
283
312
 
284
- export function mapGiteaCommitStatuses(records) {
313
+ export function mapGiteaCommitStatuses(records, { headSha } = {}) {
285
314
  return dedupeGiteaStatusRecords(records).map((s) => ({
286
315
  context: sanitizeField(s.context),
287
316
  state: normalizeGiteaStatusState(s.status ?? s.state),
288
317
  description: sanitizeField(s.description),
318
+ ...(s.target_url ? { target_url: sanitizeField(s.target_url) } : {}),
319
+ ...(headSha ? { sha: headSha } : {}),
320
+ source: 'commit_status',
289
321
  }));
290
322
  }
291
323
 
@@ -319,12 +351,19 @@ export async function whoami(ctx) {
319
351
 
320
352
  export async function branchProtection(ctx, { branchRef }) {
321
353
  requireToken();
322
- const protection = await giteaFetch(
323
- ctx.config,
324
- ctx.parsed,
325
- repoApiPath(ctx.config, 'branch_protections', branchRef),
326
- );
327
- return buildBranchProtectionFromGiteaProtection(branchRef, protection);
354
+ try {
355
+ const protection = await giteaFetch(
356
+ ctx.config,
357
+ ctx.parsed,
358
+ repoApiPath(ctx.config, 'branch_protections', branchRef),
359
+ );
360
+ return buildBranchProtectionFromGiteaProtection(branchRef, protection);
361
+ } catch (err) {
362
+ if (err?.status === 404) {
363
+ return buildBranchProtectionFromGiteaProtection(branchRef, null);
364
+ }
365
+ throw err;
366
+ }
328
367
  }
329
368
 
330
369
  export async function branchHeadSha(ctx, branchRef, { repoId } = {}) {
@@ -551,7 +590,7 @@ export function providerCapabilities() {
551
590
  host_binding: 'verified_remote_host',
552
591
  pagination: 'supported',
553
592
  write_support: true,
554
- write_commands: ['cr_open', 'status_set', 'merge'],
593
+ write_commands: ['cr_open', 'status_set', 'merge', 'issue_open'],
555
594
  ...forgeIngestCapabilityFacts(),
556
595
  ...checkPaginationCapabilityFacts({
557
596
  strategy: 'offset_limit',
@@ -600,7 +639,63 @@ export async function getPull(ctx, { number }) {
600
639
  forgeError: forgeError(ERROR_CODES.INVALID_ARGS, 'Provide --number for PR lookup'),
601
640
  });
602
641
  }
603
- return giteaFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'pulls', number));
642
+ return giteaFetchPullForView(ctx.config, ctx.parsed, number);
643
+ }
644
+
645
+ /** Raw read bound for pull view before stripping bulky fields (#478). */
646
+ const GITEA_PULL_VIEW_RAW_READ_MAX = 256 * 1024;
647
+
648
+ function stripGiteaPullBulkJsonFields(raw) {
649
+ return String(raw)
650
+ .replace(/"body"\s*:\s*"(?:\\.|[^"\\])*"/g, '"body":""')
651
+ .replace(/"body_html"\s*:\s*"(?:\\.|[^"\\])*"/g, '"body_html":""')
652
+ .replace(/"diff"\s*:\s*"(?:\\.|[^"\\])*"/g, '"diff":""')
653
+ .replace(/"patch"\s*:\s*"(?:\\.|[^"\\])*"/g, '"patch":""');
654
+ }
655
+
656
+ async function giteaFetchPullForView(config, parsed, number) {
657
+ const token = requireToken();
658
+ const url = `${apiBase(config, parsed)}${repoApiPath(config, 'pulls', number)}`;
659
+ const res = await fetchWithTimeout(url, { headers: authHeaders(token) });
660
+ if (res.status >= 300 && res.status < 400) {
661
+ const message = 'HTTP redirect rejected';
662
+ throw Object.assign(new Error(message), {
663
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
664
+ status: res.status,
665
+ });
666
+ }
667
+ const capped = await readStreamCapped(res.body, GITEA_PULL_VIEW_RAW_READ_MAX);
668
+ if (capped.truncated) {
669
+ throw Object.assign(new Error('Provider output exceeded cap'), {
670
+ forgeError: forgeError(ERROR_CODES.OVERSIZED_RAW_OUTPUT, 'Provider response exceeded byte cap'),
671
+ status: res.status,
672
+ });
673
+ }
674
+ const stripped = stripGiteaPullBulkJsonFields(capped.text);
675
+ if (Buffer.byteLength(stripped, 'utf8') > getEffectiveIngestMaxBytes().bytes) {
676
+ throw Object.assign(new Error('Provider output exceeded cap after projection'), {
677
+ forgeError: forgeError(ERROR_CODES.OVERSIZED_RAW_OUTPUT, 'Provider response exceeded byte cap'),
678
+ status: res.status,
679
+ });
680
+ }
681
+ let body;
682
+ try {
683
+ body = stripped ? JSON.parse(stripped) : null;
684
+ } catch {
685
+ throw Object.assign(new Error('Unparseable JSON from provider'), {
686
+ forgeError: forgeError(ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT, 'Provider returned invalid JSON'),
687
+ status: res.status,
688
+ });
689
+ }
690
+ if (!res.ok) {
691
+ const raw = body?.message || body?.error || res.statusText || 'API error';
692
+ const message = sanitizeField(raw) || 'API error';
693
+ throw Object.assign(new Error(message), {
694
+ forgeError: forgeError(ERROR_CODES.API_ERROR, message, res.status),
695
+ status: res.status,
696
+ });
697
+ }
698
+ return body;
604
699
  }
605
700
 
606
701
  /** Paginated open-pull scan for idempotent cr open; fail-closed when scan cap prevents proof of absence. */
@@ -651,6 +746,68 @@ export async function findOpenPullByHeadBase(ctx, head, base) {
651
746
  return null;
652
747
  }
653
748
 
749
+ function issueOpenIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
750
+ return forgeError(
751
+ ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
752
+ 'Cannot prove no open issue exists for title within scan limit; retry or open manually',
753
+ null,
754
+ {
755
+ idempotency_scan: {
756
+ pages: pagesScanned,
757
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
758
+ page_size: pageSizeUsed,
759
+ },
760
+ },
761
+ );
762
+ }
763
+
764
+ /** Paginated open-issue scan for idempotent issue open; fail-closed when scan cap prevents proof of absence. */
765
+ export async function findOpenIssueByTitle(ctx, title) {
766
+ requireToken();
767
+ const path = `${repoApiPath(ctx.config, 'issues')}?state=open`;
768
+ const pageSep = path.includes('?') ? '&' : '?';
769
+ let activeLimit = GITEA_PAGE_SIZE;
770
+
771
+ for (let page = 1; page <= MAX_OPEN_PULL_IDEMPOTENCY_PAGES; page += 1) {
772
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(
773
+ async ({ page: pageNum, limit }) => {
774
+ const body = await giteaFetch(
775
+ ctx.config,
776
+ ctx.parsed,
777
+ `${path}${pageSep}limit=${limit}&page=${pageNum}`,
778
+ );
779
+ if (!Array.isArray(body)) {
780
+ throw Object.assign(new Error('Provider returned non-array open issue list'), {
781
+ forgeError: forgeError(
782
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
783
+ 'Provider returned non-array open issue list',
784
+ ),
785
+ });
786
+ }
787
+ return body;
788
+ },
789
+ page,
790
+ activeLimit,
791
+ );
792
+ activeLimit = usedLimit;
793
+
794
+ const match =
795
+ items.find(
796
+ (issue) =>
797
+ String(issue?.state ?? '').toLowerCase() === 'open' &&
798
+ sanitizeField(issue?.title ?? '') === title,
799
+ ) ?? null;
800
+ if (match) return match;
801
+ if (items.length < usedLimit) return null;
802
+ if (page === MAX_OPEN_PULL_IDEMPOTENCY_PAGES) {
803
+ throw Object.assign(new Error('Open issue idempotency scan incomplete'), {
804
+ forgeError: issueOpenIdempotencyScanIncompleteError(page, usedLimit),
805
+ });
806
+ }
807
+ }
808
+ return null;
809
+ }
810
+
654
811
  function statusSetIdempotencyScanIncompleteError(pagesScanned, pageSizeUsed) {
655
812
  return forgeError(
656
813
  ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE,
@@ -716,12 +873,18 @@ export async function findCommitStatusByContext(ctx, sha, context) {
716
873
 
717
874
  export async function statusSet(ctx, args) {
718
875
  assertWriteCommandConfigured(ctx.config, 'status_set');
719
- const parsed = parseStatusSetArgs(args);
876
+ const { idempotencyFingerprint = null, ...rest } = args;
877
+ const parsed = parseStatusSetArgs(rest);
720
878
  const existing = await findCommitStatusByContext(ctx, parsed.sha, parsed.context);
721
879
  if (existing) {
722
880
  const existingState = normalizeStatusSetState(existing.status ?? existing.state);
723
881
  if (existingState === parsed.state) {
724
- return buildCommitStatusSetBody(existing, parsed, { reusedExisting: true });
882
+ return buildCommitStatusSetBody(existing, parsed, {
883
+ reusedExisting: true,
884
+ idempotencyFields: idempotencyFingerprint
885
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
886
+ : null,
887
+ });
725
888
  }
726
889
  }
727
890
  const payload = {
@@ -740,10 +903,48 @@ export async function statusSet(ctx, args) {
740
903
  body: JSON.stringify(payload),
741
904
  },
742
905
  );
743
- return buildCommitStatusSetBody(response, parsed);
906
+ return buildCommitStatusSetBody(response, parsed, {
907
+ idempotencyFields: idempotencyFingerprint
908
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
909
+ : null,
910
+ } );
744
911
  }
745
912
 
746
- export async function crOpen(ctx, { head, base, title, body: prBody }) {
913
+ export async function issueOpen(ctx, { title, body: issueBody, idempotencyFingerprint = null }) {
914
+ assertWriteCommandConfigured(ctx.config, 'issue_open');
915
+ const parsed = parseIssueOpenArgs({ title, body: issueBody });
916
+ const existing = await findOpenIssueByTitle(ctx, parsed.title);
917
+ if (existing) {
918
+ return buildIssueOpenedBody(
919
+ existing,
920
+ { title: parsed.title },
921
+ {
922
+ reusedExisting: true,
923
+ idempotencyFields: idempotencyFingerprint
924
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
925
+ : null,
926
+ },
927
+ );
928
+ }
929
+ const payload = { title: parsed.title };
930
+ if (parsed.body != null) payload.body = parsed.body;
931
+ const issue = await giteaFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'issues'), {
932
+ method: 'POST',
933
+ headers: { 'Content-Type': 'application/json' },
934
+ body: JSON.stringify(payload),
935
+ });
936
+ return buildIssueOpenedBody(
937
+ issue,
938
+ { title: parsed.title },
939
+ {
940
+ idempotencyFields: idempotencyFingerprint
941
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
942
+ : null,
943
+ },
944
+ );
945
+ }
946
+
947
+ export async function crOpen(ctx, { head, base, title, body: prBody, idempotencyFingerprint = null }) {
747
948
  assertWriteCommandConfigured(ctx.config, 'cr_open');
748
949
  assertGitRef(head, 'head');
749
950
  assertGitRef(base, 'base');
@@ -762,14 +963,31 @@ export async function crOpen(ctx, { head, base, title, body: prBody }) {
762
963
  }
763
964
  const existing = await findOpenPullByHeadBase(ctx, payload.head, payload.base);
764
965
  if (existing) {
765
- return buildChangeRequestOpenedBody(existing, { head, base, title }, { reusedExisting: true });
966
+ return buildChangeRequestOpenedBody(
967
+ existing,
968
+ { head, base, title },
969
+ {
970
+ reusedExisting: true,
971
+ idempotencyFields: idempotencyFingerprint
972
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: true })
973
+ : null,
974
+ },
975
+ );
766
976
  }
767
977
  const pull = await giteaFetch(ctx.config, ctx.parsed, repoApiPath(ctx.config, 'pulls'), {
768
978
  method: 'POST',
769
979
  headers: { 'Content-Type': 'application/json' },
770
980
  body: JSON.stringify(payload),
771
981
  });
772
- return buildChangeRequestOpenedBody(pull, { head, base, title });
982
+ return buildChangeRequestOpenedBody(
983
+ pull,
984
+ { head, base, title },
985
+ {
986
+ idempotencyFields: idempotencyFingerprint
987
+ ? idempotencyPacketFields(idempotencyFingerprint, { reusedExisting: false })
988
+ : null,
989
+ },
990
+ );
773
991
  }
774
992
 
775
993
  export async function mergeExecute(ctx, { number, method = 'merge', expectedHeadSha }) {
@@ -1053,6 +1271,7 @@ export async function prView(ctx, opts) {
1053
1271
  export async function prChecks(ctx, opts) {
1054
1272
  requireToken();
1055
1273
  let sha;
1274
+ let requiredContexts = [];
1056
1275
  if (opts.ref) {
1057
1276
  assertGitRef(opts.ref, 'ref');
1058
1277
  sha = gitRevParse(ctx.cwd, opts.ref);
@@ -1064,6 +1283,15 @@ export async function prChecks(ctx, opts) {
1064
1283
  } else {
1065
1284
  const pr = await getPull(ctx, opts);
1066
1285
  sha = pr.head?.sha;
1286
+ const targetBranch = pr.base?.ref;
1287
+ if (targetBranch) {
1288
+ try {
1289
+ const protection = await resolveBranchProtection(ctx, { branchRef: targetBranch });
1290
+ requiredContexts = protection.required_status_contexts ?? [];
1291
+ } catch (err) {
1292
+ if (err?.status !== 404) throw err;
1293
+ }
1294
+ }
1067
1295
  }
1068
1296
  if (!sha) {
1069
1297
  throw Object.assign(new Error('No SHA'), {
@@ -1075,14 +1303,14 @@ export async function prChecks(ctx, opts) {
1075
1303
  ctx.parsed,
1076
1304
  repoApiPath(ctx.config, 'commits', sha, 'statuses'),
1077
1305
  );
1078
- const mapped = mapGiteaCommitStatuses(statusRecords);
1079
- const conclusion = summarizeChecks(mapped);
1080
- return {
1306
+ const mapped = mapGiteaCommitStatuses(statusRecords, { headSha: sha });
1307
+ return buildPrChecksBody({
1081
1308
  forge_source_sha: sha,
1082
- check_conclusion: conclusion,
1309
+ check_conclusion: summarizeChecks(mapped),
1083
1310
  checks_truncated,
1084
1311
  statuses: mapped,
1085
- };
1312
+ required_contexts: requiredContexts,
1313
+ });
1086
1314
  }
1087
1315
 
1088
1316
  export function summarizeChecks(statuses) {
@@ -1127,6 +1355,7 @@ export async function syncPlan(ctx, remoteName = 'origin') {
1127
1355
  export const provider = {
1128
1356
  id: 'gitea-api',
1129
1357
  providerCapabilities,
1358
+ apiReachability,
1130
1359
  repoStatus,
1131
1360
  refsCompare,
1132
1361
  refsInventory,
@@ -1137,6 +1366,7 @@ export const provider = {
1137
1366
  mergePlan,
1138
1367
  syncPlan,
1139
1368
  crOpen,
1369
+ issueOpen,
1140
1370
  mergeExecute,
1141
1371
  statusSet,
1142
1372
  whoami,
@@ -1146,3 +1376,5 @@ export const provider = {
1146
1376
  crComments,
1147
1377
  forgeChanges,
1148
1378
  };
1379
+
1380
+ 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.8",
3
+ "version": "0.1.0-beta.9",
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.8"
26
+ "@remogram/core": "0.1.0-beta.9"
26
27
  }
27
28
  }